generated from coulomb/repo-seed
Scoped CMIS workspace folders with create, list, parent, path lookup, delete, and delete-tree behavior
This commit is contained in:
@@ -7,10 +7,13 @@ requests into service/runtime contracts and must not own domain behavior.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import datetime
|
||||
from email import policy
|
||||
from email.parser import BytesParser
|
||||
from importlib import metadata
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
from kontextual_engine.adapters.memory import InMemoryAssetRegistryRepository, InMemoryBlobStorage
|
||||
from kontextual_engine.core import (
|
||||
@@ -23,6 +26,7 @@ from kontextual_engine.core import (
|
||||
CMISAccessPoint,
|
||||
CMISAccessProfile,
|
||||
CMISAction,
|
||||
CMISBaseType,
|
||||
CMISDomainMapper,
|
||||
ContextEntity,
|
||||
ContextEntityType,
|
||||
@@ -188,11 +192,25 @@ AGENT_OPERATION_CATALOG: tuple[dict[str, Any], ...] = (
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CMISWorkspaceFolder:
|
||||
access_point_id: str
|
||||
object_id: str
|
||||
path: str
|
||||
name: str
|
||||
parent_id: str
|
||||
created_by: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
lifecycle: str = LifecycleState.ACTIVE.value
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServiceRuntime:
|
||||
repository: AssetRegistryRepository = field(default_factory=InMemoryAssetRegistryRepository)
|
||||
blob_storage: BlobStorage = field(default_factory=InMemoryBlobStorage)
|
||||
policy_gateway: PolicyGateway = field(default_factory=AllowAllPolicyGateway)
|
||||
cmis_workspace_folders: dict[str, dict[str, CMISWorkspaceFolder]] = field(default_factory=dict)
|
||||
api_version: str = API_VERSION
|
||||
service_name: str = "kontextual-engine"
|
||||
started_at: str = field(default_factory=lambda: utc_now().isoformat())
|
||||
@@ -427,9 +445,23 @@ class ServiceRuntime:
|
||||
return self.cmis_browser_root_object(access_point_id)
|
||||
if object_id.startswith("cmis:folder:"):
|
||||
folder_path = _cmis_folder_path(object_id) or "/"
|
||||
return cmis_browser_object(self._cmis_mapper(access_point_id).folder_projection(folder_path))
|
||||
mapper = self._cmis_mapper(access_point_id)
|
||||
if not self._cmis_folder_exists(mapper, context, folder_path):
|
||||
raise NotFoundError(
|
||||
"CMIS folder not found",
|
||||
details={"object_id": object_id, "access_point_id": access_point_id},
|
||||
)
|
||||
return cmis_browser_object(self._cmis_folder_projection(access_point_id, folder_path))
|
||||
return cmis_browser_object(self.cmis_object(access_point_id, object_id, context))
|
||||
|
||||
def cmis_browser_object_by_path(
|
||||
self,
|
||||
access_point_id: str,
|
||||
path: str,
|
||||
context: OperationContext,
|
||||
) -> dict[str, Any]:
|
||||
return cmis_browser_object(self.cmis_object_by_path(access_point_id, path, context))
|
||||
|
||||
def cmis_browser_children(
|
||||
self,
|
||||
access_point_id: str,
|
||||
@@ -456,6 +488,20 @@ class ServiceRuntime:
|
||||
) -> list[dict[str, Any]]:
|
||||
return cmis_browser_parent_list(self.cmis_object_parents(access_point_id, object_id, context))
|
||||
|
||||
def cmis_browser_parent(
|
||||
self,
|
||||
access_point_id: str,
|
||||
object_id: str,
|
||||
context: OperationContext,
|
||||
) -> dict[str, Any]:
|
||||
parents = self.cmis_object_parents(access_point_id, object_id, context).get("parents", [])
|
||||
if not parents:
|
||||
raise NotFoundError(
|
||||
"CMIS folder parent not found",
|
||||
details={"object_id": object_id, "access_point_id": access_point_id},
|
||||
)
|
||||
return cmis_browser_object(parents[0])
|
||||
|
||||
def cmis_browser_query(
|
||||
self,
|
||||
access_point_id: str,
|
||||
@@ -510,6 +556,14 @@ class ServiceRuntime:
|
||||
decision = mapper.access_point.decide_action(CMISAction.GET_OBJECT, context, resource=object_id)
|
||||
if not decision.allowed:
|
||||
raise _cmis_authorization_error(decision, "getObject")
|
||||
if object_id.startswith("cmis:folder:"):
|
||||
folder_path = _cmis_folder_path(object_id) or "/"
|
||||
if not self._cmis_folder_exists(mapper, context, folder_path):
|
||||
raise NotFoundError(
|
||||
"CMIS folder not found",
|
||||
details={"object_id": object_id, "access_point_id": access_point_id},
|
||||
)
|
||||
return self._cmis_folder_projection(access_point_id, folder_path)
|
||||
asset_id = _cmis_asset_id(object_id)
|
||||
asset = self.repository.get_asset(asset_id)
|
||||
projection = mapper.map_asset(
|
||||
@@ -530,6 +584,53 @@ class ServiceRuntime:
|
||||
)
|
||||
return projection.to_dict()
|
||||
|
||||
def cmis_object_by_path(
|
||||
self,
|
||||
access_point_id: str,
|
||||
path: str,
|
||||
context: OperationContext,
|
||||
) -> dict[str, Any]:
|
||||
mapper = self._cmis_mapper(access_point_id)
|
||||
normalized = _normalize_cmis_path(path)
|
||||
if normalized == "/":
|
||||
return cmis_browser_root_folder(mapper.access_point)
|
||||
workspace_folder = self._cmis_workspace_folder_map(access_point_id).get(normalized)
|
||||
if workspace_folder is not None:
|
||||
return self._cmis_workspace_folder_projection(mapper, workspace_folder)
|
||||
for asset in self.repository.list_assets():
|
||||
if not mapper.access_point.exposes_asset(asset, context):
|
||||
continue
|
||||
if normalized not in mapper.asset_paths(asset):
|
||||
continue
|
||||
projection = mapper.map_asset(
|
||||
asset,
|
||||
context,
|
||||
representations=self.repository.list_representations(asset_id=asset.id),
|
||||
versions=self.repository.list_versions(asset.id),
|
||||
relationship_ids=[
|
||||
f"cmis:relationship:{relationship.relationship_id}"
|
||||
for relationship in self.repository.list_relationships(source_id=asset.id)
|
||||
if self._cmis_relationship_visible(mapper, relationship, context)
|
||||
],
|
||||
metadata_records=self.repository.list_metadata_records(asset.id),
|
||||
)
|
||||
if projection is not None:
|
||||
return projection.to_dict()
|
||||
if any(
|
||||
folder.path != normalized and _path_contains(normalized, folder.path)
|
||||
for folder in self._cmis_workspace_folder_map(access_point_id).values()
|
||||
):
|
||||
return mapper.folder_projection(normalized)
|
||||
for asset in self.repository.list_assets():
|
||||
if not mapper.access_point.exposes_asset(asset, context):
|
||||
continue
|
||||
if any(asset_path != normalized and _path_contains(normalized, asset_path) for asset_path in mapper.asset_paths(asset)):
|
||||
return mapper.folder_projection(normalized)
|
||||
raise NotFoundError(
|
||||
"CMIS object path not found",
|
||||
details={"path": normalized, "access_point_id": access_point_id},
|
||||
)
|
||||
|
||||
def cmis_content_stream(
|
||||
self,
|
||||
access_point_id: str,
|
||||
@@ -598,6 +699,18 @@ class ServiceRuntime:
|
||||
decision = mapper.access_point.decide_action(CMISAction.GET_OBJECT_PARENTS, context, resource=object_id)
|
||||
if not decision.allowed:
|
||||
raise _cmis_authorization_error(decision, "getObjectParents")
|
||||
if object_id.startswith("cmis:folder:"):
|
||||
folder_path = _cmis_folder_path(object_id) or "/"
|
||||
if not self._cmis_folder_exists(mapper, context, folder_path):
|
||||
raise NotFoundError(
|
||||
"CMIS folder not found",
|
||||
details={"object_id": object_id, "access_point_id": access_point_id},
|
||||
)
|
||||
if folder_path == "/":
|
||||
return {"object_id": object_id, "parents": [], "count": 0}
|
||||
parent_path = _path_parent(folder_path)
|
||||
parent = self._cmis_folder_projection(access_point_id, parent_path)
|
||||
return {"object_id": object_id, "parents": [parent], "count": 1}
|
||||
asset_id = _cmis_asset_id(object_id)
|
||||
asset = self.repository.get_asset(asset_id)
|
||||
if not mapper.access_point.exposes_asset(asset, context):
|
||||
@@ -605,9 +718,73 @@ class ServiceRuntime:
|
||||
"CMIS object not found",
|
||||
details={"object_id": object_id, "access_point_id": access_point_id},
|
||||
)
|
||||
parents = list(mapper.parent_folders_for_asset(asset))
|
||||
explicit_cmis_path = asset.metadata.get("cmis_path")
|
||||
parent_paths = (
|
||||
[_path_parent(str(explicit_cmis_path))]
|
||||
if explicit_cmis_path
|
||||
else [parent["path"] for parent in mapper.parent_folders_for_asset(asset)]
|
||||
)
|
||||
parents = [self._cmis_folder_projection(access_point_id, path) for path in dict.fromkeys(parent_paths)]
|
||||
return {"object_id": mapper.asset_object_id(asset.id), "parents": parents, "count": len(parents)}
|
||||
|
||||
def cmis_create_folder(
|
||||
self,
|
||||
access_point_id: str,
|
||||
payload: dict[str, Any],
|
||||
context: OperationContext,
|
||||
*,
|
||||
parent_folder_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
mapper = self._cmis_mapper(access_point_id)
|
||||
decision = mapper.access_point.decide_action(CMISAction.CREATE_FOLDER, context)
|
||||
if not decision.allowed:
|
||||
raise _cmis_authorization_error(decision, "createFolder")
|
||||
properties = dict(payload.get("properties", {}))
|
||||
name = str(payload.get("name") or properties.get("cmis:name") or "").strip()
|
||||
if not name:
|
||||
raise ValidationError("CMIS folder name is required", details={"operation": "createFolder"})
|
||||
type_id = properties.get("cmis:objectTypeId", payload.get("type_id", CMISBaseType.FOLDER.value))
|
||||
if type_id not in {CMISBaseType.FOLDER.value, "kontextual:folder"}:
|
||||
raise ValidationError(
|
||||
"Unsupported CMIS folder type",
|
||||
details={"operation": "createFolder", "type_id": type_id, "supported": [CMISBaseType.FOLDER.value]},
|
||||
)
|
||||
parent_id = parent_folder_id or payload.get("folder_id") or payload.get("folderId") or "cmis-root"
|
||||
parent_path = _cmis_folder_path(parent_id) or "/"
|
||||
folder_path = _normalize_cmis_path(f"{parent_path}/{name}")
|
||||
folders = self._cmis_workspace_folder_map(access_point_id)
|
||||
if folder_path in folders:
|
||||
raise ValidationError(
|
||||
"CMIS folder already exists",
|
||||
details={"operation": "createFolder", "path": folder_path},
|
||||
)
|
||||
parent_object_id = "cmis-root" if parent_path == "/" else mapper.folder_object_id(parent_path)
|
||||
now = utc_now().isoformat()
|
||||
folder = CMISWorkspaceFolder(
|
||||
access_point_id=access_point_id,
|
||||
object_id=mapper.folder_object_id(folder_path),
|
||||
path=folder_path,
|
||||
name=_path_name(folder_path),
|
||||
parent_id=parent_object_id,
|
||||
created_by=context.actor.id,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
folders[folder.path] = folder
|
||||
return self._cmis_workspace_folder_projection(mapper, folder)
|
||||
|
||||
def cmis_browser_create_folder(
|
||||
self,
|
||||
access_point_id: str,
|
||||
payload: dict[str, Any],
|
||||
context: OperationContext,
|
||||
*,
|
||||
parent_folder_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return cmis_browser_object(
|
||||
self.cmis_create_folder(access_point_id, payload, context, parent_folder_id=parent_folder_id)
|
||||
)
|
||||
|
||||
def cmis_create_document(
|
||||
self,
|
||||
access_point_id: str,
|
||||
@@ -618,6 +795,10 @@ class ServiceRuntime:
|
||||
decision = mapper.access_point.decide_action(CMISAction.CREATE_DOCUMENT, context)
|
||||
if not decision.allowed:
|
||||
raise _cmis_authorization_error(decision, "createDocument")
|
||||
properties = dict(payload.get("properties", {}))
|
||||
name = payload.get("name") or properties.get("cmis:name")
|
||||
if not name:
|
||||
raise ValidationError("CMIS document name is required", details={"operation": "createDocument"})
|
||||
classification = Classification.from_dict(
|
||||
{
|
||||
"asset_type": payload.get("asset_type", "document"),
|
||||
@@ -640,7 +821,7 @@ class ServiceRuntime:
|
||||
)
|
||||
representations.append(representation)
|
||||
result = self.asset_service().create_asset(
|
||||
payload["name"],
|
||||
str(name),
|
||||
classification,
|
||||
context,
|
||||
asset_id=asset_id,
|
||||
@@ -648,8 +829,34 @@ class ServiceRuntime:
|
||||
metadata_records=[_metadata_record(item) for item in payload.get("metadata_records", [])],
|
||||
idempotency_key=payload.get("idempotency_key"),
|
||||
)
|
||||
folder_id = payload.get("folder_id") or payload.get("folderId")
|
||||
if folder_id:
|
||||
folder_path = _cmis_folder_path(folder_id) or "/"
|
||||
asset_path = _normalize_cmis_path(f"{folder_path}/{name}")
|
||||
metadata = {
|
||||
**result.asset.metadata,
|
||||
"cmis_path": asset_path,
|
||||
"cmis_parent_folder_id": "cmis-root"
|
||||
if folder_path == "/"
|
||||
else mapper.folder_object_id(folder_path),
|
||||
"file_name": str(name),
|
||||
}
|
||||
self.repository.save_asset(replace(result.asset, metadata=metadata))
|
||||
return self.cmis_object(access_point_id, mapper.asset_object_id(result.asset.id), context)
|
||||
|
||||
def cmis_browser_create_document(
|
||||
self,
|
||||
access_point_id: str,
|
||||
payload: dict[str, Any],
|
||||
context: OperationContext,
|
||||
*,
|
||||
parent_folder_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload = dict(payload)
|
||||
if parent_folder_id and "folder_id" not in payload and "folderId" not in payload:
|
||||
payload["folder_id"] = parent_folder_id
|
||||
return cmis_browser_object(self.cmis_create_document(access_point_id, payload, context))
|
||||
|
||||
def cmis_update_properties(
|
||||
self,
|
||||
access_point_id: str,
|
||||
@@ -729,6 +936,29 @@ class ServiceRuntime:
|
||||
decision = mapper.access_point.decide_action(CMISAction.DELETE_OBJECT, context, resource=object_id)
|
||||
if not decision.allowed:
|
||||
raise _cmis_authorization_error(decision, "deleteObject")
|
||||
if object_id.startswith("cmis:folder:"):
|
||||
folder_path = _cmis_folder_path(object_id) or "/"
|
||||
if folder_path == "/":
|
||||
raise ValidationError("CMIS root folder cannot be deleted", details={"operation": "deleteObject"})
|
||||
folders = self._cmis_workspace_folder_map(access_point_id)
|
||||
if folder_path not in folders:
|
||||
raise NotFoundError(
|
||||
"CMIS folder not found",
|
||||
details={"object_id": object_id, "access_point_id": access_point_id},
|
||||
)
|
||||
children = self._cmis_children_for_folder(mapper, context, folder_path=folder_path)
|
||||
if children:
|
||||
raise ValidationError(
|
||||
"CMIS folder is not empty",
|
||||
details={"operation": "deleteObject", "object_id": object_id, "child_count": len(children)},
|
||||
)
|
||||
del folders[folder_path]
|
||||
return {
|
||||
"object_id": object_id,
|
||||
"deleted": True,
|
||||
"lifecycle": LifecycleState.DELETE_REQUESTED.value,
|
||||
"profile": access_point_id,
|
||||
}
|
||||
asset_id = _cmis_asset_id(object_id)
|
||||
result = self.asset_service().request_delete(
|
||||
asset_id,
|
||||
@@ -744,6 +974,56 @@ class ServiceRuntime:
|
||||
"policy_decision": result.policy_decision.to_dict(),
|
||||
}
|
||||
|
||||
def cmis_delete_tree(
|
||||
self,
|
||||
access_point_id: str,
|
||||
object_id: str,
|
||||
payload: dict[str, Any],
|
||||
context: OperationContext,
|
||||
) -> dict[str, Any]:
|
||||
mapper = self._cmis_mapper(access_point_id)
|
||||
decision = mapper.access_point.decide_action(CMISAction.DELETE_TREE, context, resource=object_id)
|
||||
if not decision.allowed:
|
||||
raise _cmis_authorization_error(decision, "deleteTree")
|
||||
folder_path = _cmis_folder_path(object_id)
|
||||
if folder_path in (None, "/"):
|
||||
raise ValidationError("CMIS root folder cannot be deleteTree target", details={"operation": "deleteTree"})
|
||||
|
||||
failed_to_delete: list[str] = []
|
||||
deleted_assets = 0
|
||||
for asset in list(self.repository.list_assets()):
|
||||
if not mapper.access_point.exposes_asset(asset, context):
|
||||
continue
|
||||
if not any(_path_contains(folder_path, path) for path in mapper.asset_paths(asset)):
|
||||
continue
|
||||
try:
|
||||
self.asset_service().request_delete(
|
||||
asset.id,
|
||||
context,
|
||||
expected_current_version_id=payload.get("expected_current_version_id"),
|
||||
)
|
||||
deleted_assets += 1
|
||||
except Exception:
|
||||
failed_to_delete.append(mapper.asset_object_id(asset.id))
|
||||
|
||||
folders = self._cmis_workspace_folder_map(access_point_id)
|
||||
folder_paths = [
|
||||
path
|
||||
for path in folders
|
||||
if path == folder_path or _path_contains(folder_path, path)
|
||||
]
|
||||
for path in sorted(folder_paths, key=lambda item: item.count("/"), reverse=True):
|
||||
del folders[path]
|
||||
|
||||
return {
|
||||
"failedToDelete": failed_to_delete,
|
||||
"failed_to_delete": failed_to_delete,
|
||||
"deleted": len(folder_paths) + deleted_assets,
|
||||
"deleted_folders": len(folder_paths),
|
||||
"deleted_assets": deleted_assets,
|
||||
"profile": access_point_id,
|
||||
}
|
||||
|
||||
def cmis_query(
|
||||
self,
|
||||
access_point_id: str,
|
||||
@@ -878,6 +1158,8 @@ class ServiceRuntime:
|
||||
for asset in self.repository.list_assets()
|
||||
if mapper.access_point.exposes_asset(asset, context)
|
||||
]
|
||||
access_point_id = mapper.access_point.access_point_id
|
||||
workspace_folders = self._cmis_workspace_folder_map(access_point_id)
|
||||
if folder_path in (None, "/"):
|
||||
child_folder_paths = set()
|
||||
for asset in assets:
|
||||
@@ -885,10 +1167,23 @@ class ServiceRuntime:
|
||||
first = path.strip("/").split("/")[0]
|
||||
if first:
|
||||
child_folder_paths.add("/" + first)
|
||||
return [mapper.folder_projection(path) for path in sorted(child_folder_paths)]
|
||||
workspace_children = [
|
||||
self._cmis_workspace_folder_projection(mapper, folder)
|
||||
for folder in workspace_folders.values()
|
||||
if _path_parent(folder.path) == "/"
|
||||
]
|
||||
projection_children = [
|
||||
mapper.folder_projection(path)
|
||||
for path in sorted(child_folder_paths)
|
||||
if path not in workspace_folders
|
||||
]
|
||||
return sorted(workspace_children + projection_children, key=lambda item: item["path"])
|
||||
children: list[dict[str, Any]] = []
|
||||
folder_path = _normalize_cmis_path(folder_path)
|
||||
child_folder_paths: set[str] = set()
|
||||
for folder in workspace_folders.values():
|
||||
if _path_parent(folder.path) == folder_path:
|
||||
children.append(self._cmis_workspace_folder_projection(mapper, folder))
|
||||
for asset in assets:
|
||||
for path in mapper.asset_paths(asset):
|
||||
parent = _path_parent(path)
|
||||
@@ -909,7 +1204,90 @@ class ServiceRuntime:
|
||||
children.append(projection.to_dict())
|
||||
elif _path_parent(parent) == folder_path:
|
||||
child_folder_paths.add(parent)
|
||||
return [mapper.folder_projection(path) for path in sorted(child_folder_paths)] + children
|
||||
projection_children = [
|
||||
mapper.folder_projection(path)
|
||||
for path in sorted(child_folder_paths)
|
||||
if path not in workspace_folders
|
||||
]
|
||||
return projection_children + children
|
||||
|
||||
def _cmis_workspace_folder_map(self, access_point_id: str) -> dict[str, CMISWorkspaceFolder]:
|
||||
return self.cmis_workspace_folders.setdefault(access_point_id, {})
|
||||
|
||||
def _cmis_folder_projection(self, access_point_id: str, folder_path: str) -> dict[str, Any]:
|
||||
mapper = self._cmis_mapper(access_point_id)
|
||||
normalized = _normalize_cmis_path(folder_path)
|
||||
if normalized == "/":
|
||||
return cmis_browser_root_folder(mapper.access_point)
|
||||
folder = self._cmis_workspace_folder_map(access_point_id).get(normalized)
|
||||
if folder is not None:
|
||||
return self._cmis_workspace_folder_projection(mapper, folder)
|
||||
return mapper.folder_projection(normalized)
|
||||
|
||||
def _cmis_folder_exists(
|
||||
self,
|
||||
mapper: CMISDomainMapper,
|
||||
context: OperationContext,
|
||||
folder_path: str,
|
||||
) -> bool:
|
||||
normalized = _normalize_cmis_path(folder_path)
|
||||
if normalized == "/":
|
||||
return True
|
||||
workspace_folders = self._cmis_workspace_folder_map(mapper.access_point.access_point_id)
|
||||
if normalized in workspace_folders:
|
||||
return True
|
||||
if any(_path_contains(normalized, folder.path) for folder in workspace_folders.values()):
|
||||
return True
|
||||
for asset in self.repository.list_assets():
|
||||
if not mapper.access_point.exposes_asset(asset, context):
|
||||
continue
|
||||
if any(_path_contains(normalized, path) for path in mapper.asset_paths(asset)):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _cmis_workspace_folder_projection(
|
||||
self,
|
||||
mapper: CMISDomainMapper,
|
||||
folder: CMISWorkspaceFolder,
|
||||
) -> dict[str, Any]:
|
||||
projection = mapper.folder_projection(folder.path)
|
||||
projection["object_id"] = folder.object_id
|
||||
projection["name"] = folder.name
|
||||
projection["path"] = folder.path
|
||||
projection["properties"].update(
|
||||
{
|
||||
"cmis:objectId": folder.object_id,
|
||||
"cmis:name": folder.name,
|
||||
"cmis:objectTypeId": CMISBaseType.FOLDER.value,
|
||||
"cmis:createdBy": folder.created_by,
|
||||
"cmis:lastModifiedBy": folder.created_by,
|
||||
"cmis:creationDate": folder.created_at,
|
||||
"cmis:lastModificationDate": folder.updated_at,
|
||||
"cmis:changeToken": f"folder:{folder.updated_at}",
|
||||
"cmis:parentId": folder.parent_id,
|
||||
"cmis:description": "Adapter-managed CMIS workspace folder",
|
||||
"kontextual:workspaceFolder": True,
|
||||
}
|
||||
)
|
||||
actions = set(projection.get("allowable_actions", []))
|
||||
actions.update(
|
||||
{
|
||||
CMISAction.GET_OBJECT.value,
|
||||
CMISAction.GET_CHILDREN.value,
|
||||
CMISAction.GET_OBJECT_PARENTS.value,
|
||||
}
|
||||
)
|
||||
if mapper.access_point.profile.allow_mutations:
|
||||
actions.update(
|
||||
{
|
||||
CMISAction.CREATE_DOCUMENT.value,
|
||||
CMISAction.CREATE_FOLDER.value,
|
||||
CMISAction.DELETE_OBJECT.value,
|
||||
CMISAction.DELETE_TREE.value,
|
||||
}
|
||||
)
|
||||
projection["allowable_actions"] = sorted(actions)
|
||||
return projection
|
||||
|
||||
def _cmis_document_projections(
|
||||
self,
|
||||
@@ -2361,6 +2739,7 @@ def create_app(runtime: ServiceRuntime | None = None):
|
||||
"query",
|
||||
"object",
|
||||
"children",
|
||||
"parent",
|
||||
"parents",
|
||||
"properties",
|
||||
"allowableActions",
|
||||
@@ -2370,6 +2749,99 @@ def create_app(runtime: ServiceRuntime | None = None):
|
||||
},
|
||||
)
|
||||
|
||||
async def browser_action_payload(request: Request) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = dict(request.query_params)
|
||||
content_type = request.headers.get("content-type", "")
|
||||
content_type_lower = content_type.lower()
|
||||
body = await request.body()
|
||||
if body:
|
||||
if "application/json" in content_type_lower:
|
||||
payload.update(json.loads(body.decode("utf-8")))
|
||||
elif "multipart/form-data" in content_type_lower:
|
||||
form_values, file_values = _parse_multipart_form(content_type, body)
|
||||
payload.update(_flatten_form_values(form_values))
|
||||
for field_name, file_value in file_values.items():
|
||||
payload[field_name] = file_value["content"]
|
||||
if field_name in {"content", "contentStream", "file"} or "content" not in payload:
|
||||
payload["content"] = file_value["content"]
|
||||
payload.setdefault("media_type", file_value.get("content_type") or "application/octet-stream")
|
||||
payload.setdefault("content_filename", file_value.get("filename"))
|
||||
else:
|
||||
parsed = parse_qs(body.decode("utf-8"), keep_blank_values=True)
|
||||
payload.update(_flatten_form_values(parsed))
|
||||
properties = _cmis_browser_properties(payload)
|
||||
if properties:
|
||||
payload["properties"] = properties
|
||||
payload.setdefault("name", properties.get("cmis:name"))
|
||||
payload.setdefault("type_id", properties.get("cmis:objectTypeId"))
|
||||
return payload
|
||||
|
||||
async def cmis_browser_post_action(
|
||||
access_point_id: str,
|
||||
request: Request,
|
||||
*,
|
||||
default_object_id: str | None,
|
||||
context: OperationContext,
|
||||
) -> Any:
|
||||
payload = await browser_action_payload(request)
|
||||
action = payload.get("cmisaction") or payload.get("cmisAction") or payload.get("action")
|
||||
if action is None:
|
||||
type_id = payload.get("type_id") or payload.get("properties", {}).get("cmis:objectTypeId")
|
||||
if type_id in {CMISBaseType.DOCUMENT.value, "kontextual:document"}:
|
||||
action = "createDocument"
|
||||
elif type_id in {CMISBaseType.FOLDER.value, "kontextual:folder"}:
|
||||
action = "createFolder"
|
||||
object_id = payload.get("objectId") or payload.get("object_id") or default_object_id
|
||||
if action == "createFolder":
|
||||
return response(
|
||||
runtime.cmis_browser_create_folder,
|
||||
access_point_id,
|
||||
payload,
|
||||
context,
|
||||
parent_folder_id=object_id or payload.get("folderId") or payload.get("folder_id") or "cmis-root",
|
||||
)
|
||||
if action == "createDocument":
|
||||
return response(
|
||||
runtime.cmis_browser_create_document,
|
||||
access_point_id,
|
||||
payload,
|
||||
context,
|
||||
parent_folder_id=object_id or payload.get("folderId") or payload.get("folder_id") or "cmis-root",
|
||||
)
|
||||
if action in {"delete", "deleteObject"}:
|
||||
if not object_id:
|
||||
raise ValidationError("CMIS object id is required", details={"operation": "deleteObject"})
|
||||
return response(runtime.cmis_delete_object, access_point_id, object_id, payload, context)
|
||||
if action == "deleteTree":
|
||||
if not object_id:
|
||||
raise ValidationError("CMIS object id is required", details={"operation": "deleteTree"})
|
||||
return response(runtime.cmis_delete_tree, access_point_id, object_id, payload, context)
|
||||
if action == "updateProperties":
|
||||
if not object_id:
|
||||
raise ValidationError("CMIS object id is required", details={"operation": "updateProperties"})
|
||||
response(runtime.cmis_update_properties, access_point_id, object_id, payload, context)
|
||||
return response(runtime.cmis_browser_object, access_point_id, object_id, context)
|
||||
if action == "setContentStream":
|
||||
if not object_id:
|
||||
raise ValidationError("CMIS object id is required", details={"operation": "setContentStream"})
|
||||
response(runtime.cmis_set_content_stream, access_point_id, object_id, payload, context)
|
||||
return response(runtime.cmis_browser_object, access_point_id, object_id, context)
|
||||
raise ValidationError(
|
||||
"Unsupported CMIS Browser Binding action",
|
||||
details={
|
||||
"cmisaction": action,
|
||||
"supported": [
|
||||
"createFolder",
|
||||
"createDocument",
|
||||
"delete",
|
||||
"deleteObject",
|
||||
"deleteTree",
|
||||
"updateProperties",
|
||||
"setContentStream",
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/cmis/{access_point_id}/browser", tags=["cmis"])
|
||||
def cmis_browser_entry(
|
||||
access_point_id: str,
|
||||
@@ -2419,16 +2891,33 @@ def create_app(runtime: ServiceRuntime | None = None):
|
||||
)
|
||||
return unsupported_browser_selector(cmisselector)
|
||||
|
||||
@app.post("/cmis/{access_point_id}/browser", tags=["cmis"])
|
||||
async def cmis_browser_entry_action(
|
||||
access_point_id: str,
|
||||
request: Request,
|
||||
objectId: str | None = Query(None),
|
||||
context: OperationContext = Depends(context_from_headers),
|
||||
) -> Any:
|
||||
return await cmis_browser_post_action(
|
||||
access_point_id,
|
||||
request,
|
||||
default_object_id=objectId,
|
||||
context=context,
|
||||
)
|
||||
|
||||
@app.get("/cmis/{access_point_id}/browser/root", tags=["cmis"])
|
||||
def cmis_browser_root(
|
||||
access_point_id: str,
|
||||
cmisselector: str | None = Query(None),
|
||||
objectId: str | None = Query(None),
|
||||
path: str | None = Query(None),
|
||||
skipCount: int = Query(0),
|
||||
maxItems: int = Query(100),
|
||||
context: OperationContext = Depends(context_from_headers),
|
||||
) -> Any:
|
||||
if cmisselector in (None, "", "object"):
|
||||
if path and not objectId:
|
||||
return response(runtime.cmis_browser_object_by_path, access_point_id, path, context)
|
||||
return response(runtime.cmis_browser_object, access_point_id, objectId, context)
|
||||
if cmisselector == "children":
|
||||
return response(
|
||||
@@ -2439,13 +2928,21 @@ def create_app(runtime: ServiceRuntime | None = None):
|
||||
skip_count=skipCount,
|
||||
max_items=maxItems,
|
||||
)
|
||||
if cmisselector == "parent":
|
||||
if not objectId:
|
||||
return unsupported_browser_selector(cmisselector)
|
||||
return response(runtime.cmis_browser_parent, access_point_id, objectId, context)
|
||||
if cmisselector == "parents":
|
||||
if not objectId:
|
||||
return []
|
||||
return response(runtime.cmis_browser_parents, access_point_id, objectId, context)
|
||||
if cmisselector == "properties":
|
||||
if path and not objectId:
|
||||
return response(runtime.cmis_browser_object_by_path, access_point_id, path, context)["properties"]
|
||||
return response(runtime.cmis_browser_object, access_point_id, objectId, context)["properties"]
|
||||
if cmisselector == "allowableActions":
|
||||
if path and not objectId:
|
||||
return response(runtime.cmis_browser_object_by_path, access_point_id, path, context)["allowableActions"]
|
||||
return response(runtime.cmis_browser_object, access_point_id, objectId, context)["allowableActions"]
|
||||
if cmisselector == "policies":
|
||||
return []
|
||||
@@ -2466,6 +2963,20 @@ def create_app(runtime: ServiceRuntime | None = None):
|
||||
)
|
||||
return unsupported_browser_selector(cmisselector)
|
||||
|
||||
@app.post("/cmis/{access_point_id}/browser/root", tags=["cmis"])
|
||||
async def cmis_browser_root_action(
|
||||
access_point_id: str,
|
||||
request: Request,
|
||||
objectId: str | None = Query(None),
|
||||
context: OperationContext = Depends(context_from_headers),
|
||||
) -> Any:
|
||||
return await cmis_browser_post_action(
|
||||
access_point_id,
|
||||
request,
|
||||
default_object_id=objectId or "cmis-root",
|
||||
context=context,
|
||||
)
|
||||
|
||||
@app.get("/cmis/{access_point_id}/browser/types", tags=["cmis"])
|
||||
def cmis_types(access_point_id: str) -> dict[str, Any]:
|
||||
return response(runtime.cmis_type_definitions, access_point_id)
|
||||
@@ -2546,6 +3057,14 @@ def create_app(runtime: ServiceRuntime | None = None):
|
||||
) -> dict[str, Any]:
|
||||
return response(runtime.cmis_create_document, access_point_id, payload, context)
|
||||
|
||||
@app.post("/cmis/{access_point_id}/browser/folder", tags=["cmis"])
|
||||
def cmis_create_folder(
|
||||
access_point_id: str,
|
||||
payload: dict[str, Any],
|
||||
context: OperationContext = Depends(context_from_headers),
|
||||
) -> dict[str, Any]:
|
||||
return response(runtime.cmis_create_folder, access_point_id, payload, context)
|
||||
|
||||
@app.post("/cmis/{access_point_id}/browser/object/{object_id:path}/properties", tags=["cmis"])
|
||||
def cmis_update_properties(
|
||||
access_point_id: str,
|
||||
@@ -3073,6 +3592,81 @@ def _path_parent(path: str) -> str:
|
||||
return "/" + "/".join(parts[:-1])
|
||||
|
||||
|
||||
def _path_name(path: str) -> str:
|
||||
normalized = _normalize_cmis_path(path)
|
||||
if normalized == "/":
|
||||
return "root"
|
||||
return normalized.rstrip("/").rsplit("/", 1)[-1]
|
||||
|
||||
|
||||
def _path_contains(parent_path: str, candidate_path: str) -> bool:
|
||||
parent = _normalize_cmis_path(parent_path)
|
||||
candidate = _normalize_cmis_path(candidate_path)
|
||||
if parent == "/":
|
||||
return candidate != "/"
|
||||
return candidate == parent or candidate.startswith(parent.rstrip("/") + "/")
|
||||
|
||||
|
||||
def _parse_multipart_form(content_type: str, body: bytes) -> tuple[dict[str, list[Any]], dict[str, dict[str, Any]]]:
|
||||
message = BytesParser(policy=policy.default).parsebytes(
|
||||
b"Content-Type: " + content_type.encode("utf-8") + b"\r\nMIME-Version: 1.0\r\n\r\n" + body
|
||||
)
|
||||
values: dict[str, list[Any]] = {}
|
||||
files: dict[str, dict[str, Any]] = {}
|
||||
if not message.is_multipart():
|
||||
return values, files
|
||||
for part in message.iter_parts():
|
||||
disposition = part.get("content-disposition", "")
|
||||
if "form-data" not in disposition:
|
||||
continue
|
||||
field_name = part.get_param("name", header="content-disposition")
|
||||
if not field_name:
|
||||
continue
|
||||
content = part.get_payload(decode=True) or b""
|
||||
filename = part.get_filename()
|
||||
if filename is not None:
|
||||
files[field_name] = {
|
||||
"filename": filename,
|
||||
"content": content,
|
||||
"content_type": part.get_content_type(),
|
||||
}
|
||||
continue
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
values.setdefault(field_name, []).append(content.decode(charset, errors="replace"))
|
||||
return values, files
|
||||
|
||||
|
||||
def _flatten_form_values(values: dict[str, list[Any]]) -> dict[str, Any]:
|
||||
flattened: dict[str, Any] = {}
|
||||
for key, items in values.items():
|
||||
if len(items) == 1:
|
||||
flattened[key] = items[0]
|
||||
else:
|
||||
flattened[key] = items
|
||||
return flattened
|
||||
|
||||
|
||||
def _cmis_browser_properties(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
properties = dict(payload.get("properties", {}))
|
||||
property_ids: dict[str, str] = {}
|
||||
property_values: dict[str, Any] = {}
|
||||
for key, value in payload.items():
|
||||
if key.startswith("propertyId["):
|
||||
index = key[len("propertyId[") :].split("]", 1)[0]
|
||||
property_ids[index] = str(value)
|
||||
elif key.startswith("propertyValue["):
|
||||
index = key[len("propertyValue[") :].split("]", 1)[0]
|
||||
property_values[index] = value
|
||||
for index, property_id in property_ids.items():
|
||||
if property_id:
|
||||
properties[property_id] = property_values.get(index)
|
||||
if "cmis:name" not in properties and payload.get("name"):
|
||||
properties["cmis:name"] = payload["name"]
|
||||
if "cmis:objectTypeId" not in properties and payload.get("type_id"):
|
||||
properties["cmis:objectTypeId"] = payload["type_id"]
|
||||
return properties
|
||||
|
||||
|
||||
def _cmis_authorization_error(decision: PolicyDecision, operation: str) -> AuthorizationError:
|
||||
return AuthorizationError(
|
||||
"CMIS operation denied by access-point profile",
|
||||
|
||||
Reference in New Issue
Block a user