Scoped CMIS workspace folders with create, list, parent, path lookup, delete, and delete-tree behavior

This commit is contained in:
2026-05-08 16:47:30 +02:00
parent efb6152487
commit 06e3654aaa
6 changed files with 1103 additions and 23 deletions

View File

@@ -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",