CMIS Browser Binding serializer layer

This commit is contained in:
2026-05-08 12:27:26 +02:00
parent 6382a5a7ab
commit 54a26cdb02
9 changed files with 1172 additions and 40 deletions

View File

@@ -51,6 +51,17 @@ from kontextual_engine.core import (
stable_json_dumps,
utc_now,
)
from kontextual_engine.core.cmis import (
cmis_browser_object,
cmis_browser_object_in_folder_list,
cmis_browser_parent_list,
cmis_browser_query_result,
cmis_browser_root_folder,
cmis_browser_service_document,
cmis_browser_type_children,
cmis_browser_type_descendants,
cmis_browser_type_definition_by_id,
)
from kontextual_engine.errors import AuthorizationError, KontextualError, NotFoundError, ValidationError
from kontextual_engine.ports import AllowAllPolicyGateway, AssetRegistryRepository, BlobStorage, PolicyGateway
from kontextual_engine.services import (
@@ -339,10 +350,131 @@ class ServiceRuntime:
def cmis_repository_info(self, access_point_id: str) -> dict[str, Any]:
return self._cmis_mapper(access_point_id).repository_info()
def cmis_browser_service_document(
self,
access_point_id: str,
*,
repository_url: str,
root_folder_url: str,
) -> dict[str, Any]:
return cmis_browser_service_document(
self.cmis_repository_info(access_point_id),
repository_url=repository_url,
root_folder_url=root_folder_url,
)
def cmis_type_definitions(self, access_point_id: str) -> dict[str, Any]:
definitions = self._cmis_mapper(access_point_id).type_definitions()
return {"items": definitions, "count": len(definitions)}
def cmis_browser_type_children(
self,
access_point_id: str,
*,
type_id: str | None = None,
skip_count: int = 0,
max_items: int = 100,
include_property_definitions: bool = False,
) -> dict[str, Any]:
return cmis_browser_type_children(
self._cmis_mapper(access_point_id).type_definitions(),
type_id=type_id,
skip_count=skip_count,
max_items=max_items,
include_property_definitions=include_property_definitions,
)
def cmis_browser_type_descendants(
self,
access_point_id: str,
*,
type_id: str | None = None,
include_property_definitions: bool = False,
) -> list[dict[str, Any]]:
return cmis_browser_type_descendants(
self._cmis_mapper(access_point_id).type_definitions(),
type_id=type_id,
include_property_definitions=include_property_definitions,
)
def cmis_browser_type_definition(
self,
access_point_id: str,
*,
type_id: str | None,
) -> dict[str, Any]:
try:
return cmis_browser_type_definition_by_id(
self._cmis_mapper(access_point_id).type_definitions(),
type_id,
)
except KeyError as exc:
raise NotFoundError(
"CMIS type definition not found",
details={"access_point_id": access_point_id, "type_id": type_id},
) from exc
def cmis_browser_root_object(self, access_point_id: str) -> dict[str, Any]:
return cmis_browser_object(cmis_browser_root_folder(self._cmis_access_point(access_point_id)))
def cmis_browser_object(
self,
access_point_id: str,
object_id: str | None,
context: OperationContext,
) -> dict[str, Any]:
if object_id in (None, "", "cmis-root", "root", "/"):
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))
return cmis_browser_object(self.cmis_object(access_point_id, object_id, context))
def cmis_browser_children(
self,
access_point_id: str,
context: OperationContext,
*,
object_id: str | None = None,
skip_count: int = 0,
max_items: int = 100,
) -> dict[str, Any]:
children = self.cmis_children(
access_point_id,
context,
folder_id=object_id,
skip_count=skip_count,
max_items=max_items,
)
return cmis_browser_object_in_folder_list(children)
def cmis_browser_parents(
self,
access_point_id: str,
object_id: str,
context: OperationContext,
) -> list[dict[str, Any]]:
return cmis_browser_parent_list(self.cmis_object_parents(access_point_id, object_id, context))
def cmis_browser_query(
self,
access_point_id: str,
query: str,
context: OperationContext,
*,
skip_count: int = 0,
max_items: int = 100,
) -> dict[str, Any]:
return cmis_browser_query_result(
self.cmis_query(
access_point_id,
query,
context,
skip_count=skip_count,
max_items=max_items,
)
)
def cmis_children(
self,
access_point_id: str,
@@ -700,9 +832,12 @@ class ServiceRuntime:
}
def _cmis_mapper(self, access_point_id: str) -> CMISDomainMapper:
return CMISDomainMapper(self._cmis_access_point(access_point_id))
def _cmis_access_point(self, access_point_id: str) -> CMISAccessPoint:
for profile in _cmis_profiles():
if profile.name == access_point_id:
return CMISDomainMapper(_cmis_access_point(profile))
return _cmis_access_point(profile)
raise NotFoundError(
"CMIS access point not found",
details={"access_point_id": access_point_id, "available": [profile.name for profile in _cmis_profiles()]},
@@ -2074,12 +2209,14 @@ class ServiceRuntime:
def create_app(runtime: ServiceRuntime | None = None):
try:
from fastapi import Depends, FastAPI, Header, HTTPException, Query
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request
from fastapi.responses import JSONResponse, StreamingResponse
except ImportError as exc: # pragma: no cover - exercised when optional extra is absent
raise RuntimeError(
"FastAPI service dependencies are not installed. Install kontextual-engine[service]."
) from exc
globals()["Request"] = Request
globals()["StreamingResponse"] = StreamingResponse
runtime = runtime or ServiceRuntime()
app = FastAPI(
@@ -2205,9 +2342,129 @@ def create_app(runtime: ServiceRuntime | None = None):
def cmis_access_points() -> dict[str, Any]:
return response(runtime.cmis_access_points)
def browser_urls(request: Request, access_point_id: str) -> tuple[str, str]:
return (
str(request.url_for("cmis_browser_entry", access_point_id=access_point_id)),
str(request.url_for("cmis_browser_root", access_point_id=access_point_id)),
)
def unsupported_browser_selector(selector: str | None) -> dict[str, Any]:
raise ValidationError(
"Unsupported CMIS Browser Binding selector",
details={
"cmisselector": selector,
"supported": [
"repositoryInfo",
"typeChildren",
"typeDescendants",
"typeDefinition",
"query",
"object",
"children",
"parents",
"properties",
"allowableActions",
"policies",
"content",
],
},
)
@app.get("/cmis/{access_point_id}/browser", tags=["cmis"])
def cmis_repository_info(access_point_id: str) -> dict[str, Any]:
return response(runtime.cmis_repository_info, access_point_id)
def cmis_browser_entry(
access_point_id: str,
request: Request,
cmisselector: str | None = Query(None),
typeId: str | None = Query(None),
includePropertyDefinitions: bool = Query(False),
q: str | None = Query(None),
skipCount: int = Query(0),
maxItems: int = Query(100),
context: OperationContext = Depends(context_from_headers),
) -> Any:
repository_url, root_folder_url = browser_urls(request, access_point_id)
if cmisselector in (None, "", "repositoryInfo"):
return response(
runtime.cmis_browser_service_document,
access_point_id,
repository_url=repository_url,
root_folder_url=root_folder_url,
)
if cmisselector == "typeChildren":
return response(
runtime.cmis_browser_type_children,
access_point_id,
type_id=typeId,
skip_count=skipCount,
max_items=maxItems,
include_property_definitions=includePropertyDefinitions,
)
if cmisselector == "typeDescendants":
return response(
runtime.cmis_browser_type_descendants,
access_point_id,
type_id=typeId,
include_property_definitions=includePropertyDefinitions,
)
if cmisselector == "typeDefinition":
return response(runtime.cmis_browser_type_definition, access_point_id, type_id=typeId)
if cmisselector == "query":
return response(
runtime.cmis_browser_query,
access_point_id,
q or "SELECT * FROM cmis:document",
context,
skip_count=skipCount,
max_items=maxItems,
)
return unsupported_browser_selector(cmisselector)
@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),
skipCount: int = Query(0),
maxItems: int = Query(100),
context: OperationContext = Depends(context_from_headers),
) -> Any:
if cmisselector in (None, "", "object"):
return response(runtime.cmis_browser_object, access_point_id, objectId, context)
if cmisselector == "children":
return response(
runtime.cmis_browser_children,
access_point_id,
context,
object_id=objectId,
skip_count=skipCount,
max_items=maxItems,
)
if cmisselector == "parents":
if not objectId:
return []
return response(runtime.cmis_browser_parents, access_point_id, objectId, context)
if cmisselector == "properties":
return response(runtime.cmis_browser_object, access_point_id, objectId, context)["properties"]
if cmisselector == "allowableActions":
return response(runtime.cmis_browser_object, access_point_id, objectId, context)["allowableActions"]
if cmisselector == "policies":
return []
if cmisselector == "content":
if not objectId:
return unsupported_browser_selector(cmisselector)
result = response(runtime.cmis_content_stream_bytes, access_point_id, objectId, context)
representation = result.representation
return StreamingResponse(
result.chunks,
media_type=representation.media_type,
headers={
"Content-Length": str(representation.size_bytes),
"ETag": representation.digest,
"X-Kontextual-Representation-Id": representation.representation_id,
"X-Kontextual-Storage-Ref": representation.storage_ref or "",
},
)
return unsupported_browser_selector(cmisselector)
@app.get("/cmis/{access_point_id}/browser/types", tags=["cmis"])
def cmis_types(access_point_id: str) -> dict[str, Any]:
@@ -2251,7 +2508,7 @@ def create_app(runtime: ServiceRuntime | None = None):
access_point_id: str,
object_id: str,
context: OperationContext = Depends(context_from_headers),
) -> StreamingResponse:
) -> Any:
result = response(runtime.cmis_content_stream_bytes, access_point_id, object_id, context)
representation = result.representation
return StreamingResponse(
@@ -2391,7 +2648,7 @@ def create_app(runtime: ServiceRuntime | None = None):
asset_id: str,
representation_id: str,
context: OperationContext = Depends(context_from_headers),
) -> StreamingResponse:
) -> Any:
result = response(runtime.representation_content_stream, asset_id, representation_id, context)
representation = result.representation
return StreamingResponse(
@@ -2772,9 +3029,10 @@ def _cmis_profiles() -> tuple[CMISAccessProfile, ...]:
def _cmis_access_point(profile: CMISAccessProfile) -> CMISAccessPoint:
repository_id = profile.name if profile.name == "compat-tck" else f"kontextual-{profile.name}"
return CMISAccessPoint(
access_point_id=profile.name,
repository_id=f"kontextual-{profile.name}",
repository_id=repository_id,
profile=profile,
base_path=f"/cmis/{profile.name}/browser",
metadata={"repository_name": f"Kontextual Engine {profile.name}"},