From dc32be36fbc6fbc26b65129afc5644ee9976e5da Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 11 May 2026 12:28:36 +0200 Subject: [PATCH] CMIS Browser Binding fixes --- docs/cmis-1-1-capability-scorecard.md | 58 +- ...-tck-wp0014-evidence-2026-05-08T153316Z.md | 99 ++ ...-tck-wp0014-evidence-2026-05-08T164334Z.md | 109 +++ src/kontextual_engine/api/app.py | 855 ++++++++++++++++-- src/kontextual_engine/core/cmis.py | 100 +- tests/cmis/test_cmis_browser_binding_api.py | 175 +++- .../cmis/test_cmis_runtime_browser_binding.py | 53 +- ...NT-WP-0014-cmis-object-content-maturity.md | 94 +- 8 files changed, 1422 insertions(+), 121 deletions(-) create mode 100644 docs/cmis-opencmis-tck-wp0014-evidence-2026-05-08T153316Z.md create mode 100644 docs/cmis-opencmis-tck-wp0014-evidence-2026-05-08T164334Z.md diff --git a/docs/cmis-1-1-capability-scorecard.md b/docs/cmis-1-1-capability-scorecard.md index 8d8c303..0ed8e6d 100644 --- a/docs/cmis-1-1-capability-scorecard.md +++ b/docs/cmis-1-1-capability-scorecard.md @@ -2,16 +2,19 @@ Date: 2026-05-07 -Evidence update: the 2026-05-08 OpenCMIS TCK compatibility implementation -resolved the initial Browser Binding session blocker. The latest run, -`run-20260508T092113Z`, completed the selected baseline with `38` passing -repository/type cases, one local HTTP transport warning, and `22` object/content -skips caused by the current non-creatable folder profile. See -`docs/cmis-opencmis-tck-implementation-evidence-2026-05-08T092113Z.md`. +Evidence update: the 2026-05-08 WP-0014 OpenCMIS pass moved the selected +baseline beyond the original folder-creatable skip boundary and through several +object/content maturity issues. The latest run, `run-20260508T164334Z`, reports +`repository-type` as warning-only and reduces `object-content` to specific +semantic gaps: invalid-type exception mapping, bulk update, content deletion, +change-token conflict handling, copy/create-from-source, and one range +classification warning. See +`docs/cmis-opencmis-tck-wp0014-evidence-2026-05-08T164334Z.md`. -The score below remains a product-depth estimate against mature CMIS products; -the evidence-backed TCK preparation score for the selected baseline is `23.81` -with `2/9` capability groups covered. +The score below remains a product-depth estimate against mature CMIS products. +The selected OpenCMIS baseline still exits as `infrastructure_error`, but the +failure frontier is now narrow and capability-specific rather than basic +Browser Binding shape or navigation. Status: baseline scorecard for the current Browser Binding subset. @@ -71,9 +74,9 @@ CMIS interoperability importance rather than engine-internal importance. | Metric | Score | | --- | ---: | -| Weighted CMIS 1.1 depth vs Hyland Alfresco benchmark | 42% | -| Controlled-client Browser Binding usefulness | 58% | -| Broad commodity CMIS client compatibility | 35% | +| Weighted CMIS 1.1 depth vs Hyland Alfresco benchmark | 51% | +| Controlled-client Browser Binding usefulness | 70% | +| Broad commodity CMIS client compatibility | 46% | Interpretation: the current CMIS layer is a credible Browser Binding subset for known clients and profile-specific integrations. It is not yet a broad ECM/CMIS @@ -83,41 +86,46 @@ replacement surface. | CMIS capability area | Weight | Current depth | Most worthy contender | Gap basis behind the percentage | | --- | ---: | ---: | --- | --- | -| Repository service and repository info | 5 | 75% | Hyland Alfresco ACS | - Repository info and conservative capability flags exist.
- Unsupported feature catalog exists.
- Missing exact service-document parity and external TCK evidence. | -| Type definitions | 6 | 45% | Hyland Alfresco ACS | - Base types and content stream properties exist.
- No mutable types or custom schema/type management.
- No broad property definition model beyond current projected fields. | -| Navigation service | 8 | 40% | Hyland Alfresco ACS | - Root and folder-scoped children exist.
- Projection-only parents exist.
- Missing `getDescendants`, `getFolderTree`, object-by-path parity, and real filing mutations. | -| Object read service | 10 | 70% | Hyland Alfresco ACS | - Object envelopes, properties, content descriptors, ACL projection, relationships, and allowable actions exist.
- Missing selector/property-filter fidelity and full Browser Binding response parity.
- Deleted/hidden objects are now correctly not exposed. | -| Object write service | 8 | 35% | Hyland Alfresco ACS | - `createDocument`, custom metadata updates, content stream set, and delete-request lifecycle exist.
- No createFolder, moveObject, standard `cmis:*` property mutation, or physical delete semantics.
- Delete is intentionally governed, not raw repository removal. | -| Content stream read/write | 8 | 65% | Hyland Alfresco ACS | - Byte streaming and deduplicating `setContentStream` exist.
- Digest verification and governed access exist.
- Missing append/delete stream, multipart Browser Binding parity, range handling, and client-tested large stream workflows. | +| Repository service and repository info | 5 | 82% | Hyland Alfresco ACS | - Repository info and conservative capability flags exist.
- Unsupported feature catalog exists.
- OpenCMIS `repository-type` is warning-only, with the remaining warning caused by local HTTP. | +| Type definitions | 6 | 52% | Hyland Alfresco ACS | - Base types and nullable content stream properties exist.
- No mutable types or custom schema/type management.
- Property definition depth remains intentionally narrow. | +| Navigation service | 8 | 58% | Hyland Alfresco ACS | - Root and folder-scoped children, path lookup, folder parent lookup, and parent path segments work.
- Projection-only parents exist.
- Missing `getDescendants`, `getFolderTree`, and real filing mutations. | +| Object read service | 10 | 78% | Hyland Alfresco ACS | - Object envelopes, properties, content descriptors, ACL projection, relationships, allowable actions, property filters, and path-addressed Browser Binding reads exist.
- Deleted/hidden objects are now correctly not exposed.
- Remaining read-side gaps are mostly around optional services and exception shape. | +| Object write service | 8 | 58% | Hyland Alfresco ACS | - `createDocument`, `createFolder`, scoped `moveObject`, folder rename, selected standard property updates, custom metadata updates, content stream set, and delete-request lifecycle exist.
- No bulk update, copy/create-from-source, broad filing mutation, or physical delete semantics.
- Delete is intentionally governed, not raw repository removal. | +| Content stream read/write | 8 | 74% | Hyland Alfresco ACS | - Byte streaming, explicit content headers, multipart Browser Binding create, deduplicating `setContentStream`, no-content compatibility streams, and partial body slicing exist.
- Digest verification and governed access exist.
- Remaining gaps are delete-content semantics, append semantics, change-token conflicts, and one range classification warning. | | Versioning service | 8 | 25% | Hyland Alfresco ACS | - Version properties can be projected from engine versions.
- No checkout/checkin/cancelCheckout/PWC services.
- No version history route or all-versions query behavior. | | Discovery/query | 8 | 25% | Hyland Alfresco ACS | - Narrow document select subset exists.
- Unsupported joins/order-by return diagnostics.
- Missing CMIS SQL predicates, type joins, full-text, ordering, and rich projection rules. | | Relationships | 5 | 60% | Hyland Alfresco ACS | - Relationship object projection and source filtering exist.
- Visibility gates prevent protected relationship leakage.
- Missing full relationship service filters, relationship creation through CMIS, and type hierarchy maturity. | | ACL service | 6 | 35% | Hyland Alfresco ACS | - Discover-only ACL projection exists.
- `applyACL` is blocked as not implemented.
- Missing inherited/direct ACL fidelity, propagation, ACL mutation, and repository principal model. | | Policy service | 3 | 10% | Hyland Alfresco ACS | - Native policy decisions govern exposure.
- No CMIS policy objects, `applyPolicy`, `removePolicy`, or `getAppliedPolicies` service surface.
- Explicitly unsupported. | -| Change log | 5 | 55% | Hyland Alfresco ACS | - Audit-backed object-id change entries and paging exist.
- Missing full change token durability semantics and richer change event typing.
- Not yet proven against external CMIS clients. | +| Change log | 5 | 55% | Hyland Alfresco ACS | - Audit-backed object-id change entries and paging exist.
- Missing CMIS update-conflict behavior for reused change tokens and richer change event typing.
- Change-token maturity is now directly visible in OpenCMIS object/content. | | Multi-filing and unfiling | 4 | 25% | Hyland Alfresco ACS | - Projection-only parent maps exist and are useful for navigation.
- Standard CMIS `capabilityMultifiling` is correctly false.
- No add/remove filing mutations or canonical folder membership model. | | Renditions | 3 | 15% | Hyland Alfresco ACS | - Native representations could become rendition candidates later.
- CMIS rendition capability is currently `none`.
- No rendition taxonomy or rendition stream routes. | | Retention and hold | 2 | 5% | OpenText / Hyland governance stacks | - Native governance metadata can represent intent later.
- No CMIS retention/hold model or mutation services.
- Explicitly unsupported. | | Bulk update | 2 | 5% | Hyland Alfresco ACS | - Native batch/error envelopes exist elsewhere in the engine.
- No CMIS `bulkUpdateProperties` behavior.
- Explicitly unsupported. | -| Browser Binding protocol fidelity | 7 | 45% | Hyland Alfresco ACS | - Browser-style routes and JSON envelopes exist.
- FastAPI route shapes are pragmatic, not complete CMIS Browser Binding selector/action parity.
- Route-level tests skip without optional service dependencies. | +| Browser Binding protocol fidelity | 7 | 66% | Hyland Alfresco ACS | - Browser-style routes, JSON envelopes, action aliases, multipart forms, path-addressed root routes, property filters, path segments, and range responses exist.
- Optional actions and CMIS exception mapping remain incomplete.
- Route-level CMIS tests run under the service extras and OpenCMIS now exercises object/content deeply. | | AtomPub binding | 2 | 0% | Hyland Alfresco ACS | - No AtomPub/XML service document or feeds.
- Intentionally deferred until monetized need. | | Web Services binding | 2 | 0% | Hyland Alfresco ACS | - No SOAP/WSDL stack.
- Intentionally deferred until monetized need. | -| External conformance evidence | 3 | 35% | OpenCMIS TCK against Alfresco-like server behavior | - OpenCMIS Browser Binding session creation now succeeds against `compat-tck`.
- Selected `repository-type` baseline completes with no failures and one local HTTP warning.
- `object-content` reaches parsed cases but skips because `cmis:folder` is not creatable; broader groups and third-party client matrix are still missing. | +| External conformance evidence | 3 | 58% | OpenCMIS TCK against Alfresco-like server behavior | - OpenCMIS Browser Binding session creation succeeds against `compat-tck`.
- Selected `repository-type` baseline is warning-only.
- `object-content` executes concrete CRUD/content cases and is now blocked by a short list of semantic gaps rather than startup, path, paging, or basic content-read failures. | -Weighted result from this table: **42%**. +Weighted result from this table: **51%**. ## Most Important Gaps 1. **External conformance expansion** - Keep the selected OpenCMIS TCK baseline running against `compat-tck`. - - Decide whether to add TCK-only `createFolder` support or keep CRUD/content - skips as a deliberate profile boundary. - - Expand selected groups after the supported capability boundary is agreed. + - Close or explicitly waive the remaining object/content gaps: exception + mapping, bulk update, delete content, change-token conflicts, copy, and + offset-zero range classification. + - Expand selected groups after the supported object/content baseline is + stable. 2. **Browser Binding fidelity** - Align route/action/selector shapes more closely with CMIS Browser Binding. - Add non-skipped FastAPI route tests in CI with service extras installed. - Add client smoke tests with Apache Chemistry/OpenCMIS where feasible. + - Return CMIS-specific exception classes/statuses instead of generic runtime + exceptions where OpenCMIS distinguishes invalid argument, constraint, and + update conflict. 3. **Query depth** - Add a real CMIS SQL subset parser instead of a two-query allowlist. diff --git a/docs/cmis-opencmis-tck-wp0014-evidence-2026-05-08T153316Z.md b/docs/cmis-opencmis-tck-wp0014-evidence-2026-05-08T153316Z.md new file mode 100644 index 0000000..07a8700 --- /dev/null +++ b/docs/cmis-opencmis-tck-wp0014-evidence-2026-05-08T153316Z.md @@ -0,0 +1,99 @@ +# CMIS WP-0014 OpenCMIS Evidence - 2026-05-08T15:33:16Z + +## Scope + +This note records the second WP-0014 maturity pass for the CMIS 1.1 Browser +Binding adapter. The pass followed the earlier folder/action work and focused +on OpenCMIS compatibility issues that fit the engine architecture naturally. + +## Implemented Since Prior Evidence + +- Added Browser Binding `cmisaction=update` as an alias for + `updateProperties`. +- Added scoped `cmisaction=move` support for CMIS-authored workspace documents + and adapter-managed workspace folders. +- Added URL-path Browser Binding routes under `/browser/root/{path}` for object, + children, parent, parents, properties, allowable actions, content, and POST + actions against path-addressed objects. +- Normalized stored CMIS content stream media types and emitted explicit + `Content-Type` headers so text streams do not acquire an unwanted charset. +- Accepted metadata-backed standard property updates for `cmis:name`, + `cmis:description`, and `cmis:secondaryObjectTypeIds`. +- Honored `cmis:secondaryObjectTypeIds` during document creation. +- Rejected invalid `createDocument` type ids, such as `cmis:folder`. +- Removed non-standard `cmis:path` from document property definitions and + document object projections while retaining folder `cmis:path`. + +## Local Verification + +Focused CMIS suite: + +```bash +.venv/bin/python -m pytest tests/cmis -q +``` + +Result: + +- `48 passed` +- Performance monitor warnings appeared on two Browser Binding tests; these + should be watched across additional runs before treating them as a regression. + +## OpenCMIS Run + +The final run used an isolated temporary target on port `8010` to avoid +colliding with other local services: + +```bash +cd /home/worsch/guide-board +source /home/worsch/open-cmis-tck/.local/toolchains/env.sh +PYTHONPATH=src python3 -m guide_board \ + --extension-dir ../open-cmis-tck \ + run \ + --target /tmp/kontextual-cmis-compat-8010.json \ + --assessment ../open-cmis-tck/profiles/assessments/cmis-browser-baseline.json \ + --output-dir /tmp/open-cmis-tck-kontextual-wp14-20260508T153146Z +``` + +Result: + +- Run ID: `run-20260508T153316Z` +- Run directory: `/tmp/open-cmis-tck-kontextual-wp14-20260508T153146Z` +- Harness status: `infrastructure_error` +- Maturity scorecard: + `/tmp/open-cmis-tck-kontextual-wp14-20260508T153146Z/reports/cmis-maturity-scorecard.md` +- Maturity score: `19.05` +- Coverage: `2/9` groups + +## Capability Interpretation + +- `repository-type`: improved from failing to `warning` / partial. Repository + info and type metadata are now usable enough for the selected baseline, with + warnings instead of hard failures. +- `object-content`: still infrastructure-blocked in guide-board classification, + but the blockers are now concrete object/content edge cases rather than + Browser Binding startup, folder creation, MIME, or secondary-type basics. + +## Remaining Frontier + +- `getObjectByPath` still fails in several OpenCMIS child checks for paths such + as `/test-folder/test-folder`; this likely needs tighter folder/document + child path-segment semantics and cleanup behavior investigation. +- Creating a document without content still leads OpenCMIS into a missing + content-stream exception. We need decide whether CMIS-created no-content + documents should expose an empty stream or clearer no-content semantics. +- Invalid-type errors are now correctly rejected, but OpenCMIS still classifies + our HTTP 422 as a runtime warning rather than a clean CMIS constraint + response. +- Folder `cmis:path` still appears when property filters request narrower + property sets. Operation-context filtering for properties, ACLs, allowable + actions, and path segments remains a compatibility gap. +- `bulkUpdate`, `deleteContent`, and some change-token tests still receive + unsupported-action or validation responses. These should be handled either by + natural implementation or sharper unsupported behavior. + +## Conclusion + +The CMIS adapter foundation is sounder and more standards-shaped after this +pass. We should keep WP-0014 active for the remaining object/content frontier, +but the work has moved from "basic Browser Binding compatibility" into specific +CMIS behavior polish and optional-service boundary decisions. diff --git a/docs/cmis-opencmis-tck-wp0014-evidence-2026-05-08T164334Z.md b/docs/cmis-opencmis-tck-wp0014-evidence-2026-05-08T164334Z.md new file mode 100644 index 0000000..17ff85e --- /dev/null +++ b/docs/cmis-opencmis-tck-wp0014-evidence-2026-05-08T164334Z.md @@ -0,0 +1,109 @@ +# CMIS OpenCMIS TCK Evidence - WP-0014 - 2026-05-08T16:43:34Z + +## Run + +- Run ID: `run-20260508T164334Z` +- Harness: `guide-board` with external extension `/home/worsch/open-cmis-tck` +- Target: `kontextual-cmis-compat` +- Endpoint under test: `http://127.0.0.1:8010/cmis/compat-tck/browser` +- Output directory: + `/tmp/open-cmis-tck-kontextual-wp14-20260508T1643Z` +- Assessment package: + `/tmp/open-cmis-tck-kontextual-wp14-20260508T1643Z/reports/assessment-package.json` +- Report: + `/tmp/open-cmis-tck-kontextual-wp14-20260508T1643Z/reports/report.md` + +Command shape: + +```sh +cd /home/worsch/guide-board +PYTHONPATH=src python3 -m guide_board \ + --extension-dir ../open-cmis-tck \ + run \ + --target /tmp/kontextual-cmis-compat-8010.json \ + --assessment ../open-cmis-tck/profiles/assessments/cmis-browser-baseline.json \ + --output-dir /tmp/open-cmis-tck-kontextual-wp14-20260508T1643Z +``` + +The local Java/Maven toolchain from `/home/worsch/open-cmis-tck/.local/toolchains` +was supplied in the command environment. + +## Internal Verification + +- Focused CMIS tests: + `.venv/bin/python -m pytest tests/cmis/test_cmis_runtime_browser_binding.py tests/cmis/test_cmis_browser_binding_api.py -q` + -> `20 passed`. +- Full suite: + `.venv/bin/python -m pytest -q` + -> `160 passed, 14 skipped`. + +## OpenCMIS Result Summary + +Overall Guide Board status remains `infrastructure_error` because the +`object-content` group still contains unsupported or incomplete CMIS services. + +Normalized group counts: + +- `repository-type`: `38 pass`, `2 info`, `1 skipped`, `1 warning`. +- `object-content`: `12 info`, `5 skipped`, `3 warning`, `3 fail`, + `3 infrastructure_error`. + +The sole `repository-type` warning is local HTTP rather than HTTPS: +`HTTPS is not used. Credentials might be transferred as plain text!` + +## Improvements Since `run-20260508T153316Z` + +- Fixed Browser Binding parent `relativePathSegment`; OpenCMIS no longer builds + invalid paths such as `/folder/folder` for documents. +- Added Browser Binding property filtering and optional envelope trimming for + `filter`, `includeAllowableActions`, `includeACL`, and `includePathSegment`. +- Changed Browser Binding children `numItems` to report the total child count + instead of page length. +- Added range-aware content responses with sliced bodies, `Content-Length`, + `Content-Range`, and `206` for partial requests. +- Added no-content document compatibility streams while keeping document content + stream properties nullable. +- Added stable object-id behavior for adapter-managed folder rename/update. +- Added `setContent` and `deleteContent` Browser Binding action aliases over the + existing content-stream service boundary. +- Preserved existing blob deduplication and digest verification paths. + +Removed or reduced OpenCMIS frontier items: + +- `getObjectByPath` path-segment failures are gone. +- Create/delete document paging `numItems` failures are gone. +- Update Smoke Test folder rename/object-id failures are gone. +- Operation Context ACL delivery warning is gone. +- Most content range warnings are gone. +- No-content document retrieval no longer aborts with `Representation not found`. + +## Remaining Frontier + +These are now the concrete maturity gaps visible in the selected OpenCMIS +baseline: + +- **Invalid type exception mapping**: invalid document/folder type creation + returns HTTP `422`, which OpenCMIS reports as `CmisRuntimeException` instead + of a narrower CMIS invalid-argument/constraint exception. +- **Bulk update**: `cmisaction=bulkUpdate` is still unsupported and reports + `Unprocessable Entity`. +- **Delete content semantics**: `deleteContentStream` is accepted through the + alias layer, but OpenCMIS still observes content after delete. The adapter + needs a stronger natural representation-removal or tombstone model. +- **Change tokens**: repeated property/content updates with the same change token + do not produce CMIS update-conflict behavior yet. +- **Copy/create-from-source**: `createDocumentFromSource` remains unsupported. +- **Range classification**: the remaining range warning is for an offset-zero + full-stream request being marked as partial. + +## Interpretation + +The CMIS layer is now past the early Browser Binding and basic object/content +shape problems. The remaining failures are concentrated in specific CMIS +service semantics: exception mapping, content deletion, change token conflict +handling, copy support, and bulk update. + +This is a healthy architectural signal: the adapter continues to map naturally +onto native asset, representation, blob, metadata, policy, and audit services. +The remaining work should be handled as explicit compatibility choices rather +than broad ECM reimplementation. diff --git a/src/kontextual_engine/api/app.py b/src/kontextual_engine/api/app.py index 0569558..95bf881 100644 --- a/src/kontextual_engine/api/app.py +++ b/src/kontextual_engine/api/app.py @@ -67,7 +67,7 @@ from kontextual_engine.core.cmis import ( 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.ports import AllowAllPolicyGateway, AssetRegistryRepository, BlobRef, BlobStorage, PolicyGateway from kontextual_engine.services import ( AssetIngestionService, AssetQueryRequest, @@ -76,6 +76,7 @@ from kontextual_engine.services import ( ContextEntityQueryRequest, RelationshipQueryRequest, RepresentationContentService, + RepresentationContentStream, RetrievalFeedbackRequest, TransformationRequest, TransformationService, @@ -432,35 +433,83 @@ class ServiceRuntime: 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_root_object( + self, + access_point_id: str, + *, + property_filter: str | None = None, + include_allowable_actions: bool = True, + include_acl: bool = True, + ) -> dict[str, Any]: + return cmis_browser_object( + cmis_browser_root_folder(self._cmis_access_point(access_point_id)), + property_filter=property_filter, + include_allowable_actions=include_allowable_actions, + include_acl=include_acl, + ) def cmis_browser_object( self, access_point_id: str, object_id: str | None, context: OperationContext, + *, + property_filter: str | None = None, + include_allowable_actions: bool = True, + include_acl: bool = True, ) -> dict[str, Any]: if object_id in (None, "", "cmis-root", "root", "/"): - return self.cmis_browser_root_object(access_point_id) + return self.cmis_browser_root_object( + access_point_id, + property_filter=property_filter, + include_allowable_actions=include_allowable_actions, + include_acl=include_acl, + ) if object_id.startswith("cmis:folder:"): folder_path = _cmis_folder_path(object_id) or "/" mapper = self._cmis_mapper(access_point_id) + workspace_folder = self._cmis_workspace_folder_by_object_id(access_point_id, object_id) + if workspace_folder is not None: + return cmis_browser_object( + self._cmis_workspace_folder_projection(mapper, workspace_folder), + property_filter=property_filter, + include_allowable_actions=include_allowable_actions, + include_acl=include_acl, + ) 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)) + return cmis_browser_object( + self._cmis_folder_projection(access_point_id, folder_path), + property_filter=property_filter, + include_allowable_actions=include_allowable_actions, + include_acl=include_acl, + ) + return cmis_browser_object( + self.cmis_object(access_point_id, object_id, context), + property_filter=property_filter, + include_allowable_actions=include_allowable_actions, + include_acl=include_acl, + ) def cmis_browser_object_by_path( self, access_point_id: str, path: str, context: OperationContext, + *, + property_filter: str | None = None, + include_allowable_actions: bool = True, + include_acl: bool = True, ) -> dict[str, Any]: - return cmis_browser_object(self.cmis_object_by_path(access_point_id, path, context)) + return cmis_browser_object( + self.cmis_object_by_path(access_point_id, path, context), + property_filter=property_filter, + include_allowable_actions=include_allowable_actions, + include_acl=include_acl, + ) def cmis_browser_children( self, @@ -470,6 +519,10 @@ class ServiceRuntime: object_id: str | None = None, skip_count: int = 0, max_items: int = 100, + property_filter: str | None = None, + include_allowable_actions: bool = True, + include_acl: bool = True, + include_path_segment: bool = True, ) -> dict[str, Any]: children = self.cmis_children( access_point_id, @@ -478,15 +531,26 @@ class ServiceRuntime: skip_count=skip_count, max_items=max_items, ) - return cmis_browser_object_in_folder_list(children) + return cmis_browser_object_in_folder_list( + children, + property_filter=property_filter, + include_allowable_actions=include_allowable_actions, + include_acl=include_acl, + include_path_segment=include_path_segment, + ) def cmis_browser_parents( self, access_point_id: str, object_id: str, context: OperationContext, + *, + include_relative_path_segment: bool = True, ) -> list[dict[str, Any]]: - return cmis_browser_parent_list(self.cmis_object_parents(access_point_id, object_id, context)) + return cmis_browser_parent_list( + self.cmis_object_parents(access_point_id, object_id, context), + include_relative_path_segment=include_relative_path_segment, + ) def cmis_browser_parent( self, @@ -558,6 +622,9 @@ class ServiceRuntime: raise _cmis_authorization_error(decision, "getObject") if object_id.startswith("cmis:folder:"): folder_path = _cmis_folder_path(object_id) or "/" + workspace_folder = self._cmis_workspace_folder_by_object_id(access_point_id, object_id) + if workspace_folder is not None: + return self._cmis_workspace_folder_projection(mapper, workspace_folder) if not self._cmis_folder_exists(mapper, context, folder_path): raise NotFoundError( "CMIS folder not found", @@ -569,7 +636,7 @@ class ServiceRuntime: projection = mapper.map_asset( asset, context, - representations=self.repository.list_representations(asset_id=asset.id), + representations=self._cmis_asset_representations(asset), versions=self.repository.list_versions(asset.id), relationship_ids=[ f"cmis:relationship:{relationship.relationship_id}" @@ -605,7 +672,7 @@ class ServiceRuntime: projection = mapper.map_asset( asset, context, - representations=self.repository.list_representations(asset_id=asset.id), + representations=self._cmis_asset_representations(asset), versions=self.repository.list_versions(asset.id), relationship_ids=[ f"cmis:relationship:{relationship.relationship_id}" @@ -667,6 +734,44 @@ class ServiceRuntime: "CMIS object not found", details={"object_id": object_id, "access_point_id": access_point_id}, ) + if asset.metadata.get("cmis_content_deleted") or not self._cmis_asset_representations(asset): + digest = content_digest(b"") + representation = AssetRepresentation( + asset_id=asset_id, + kind=RepresentationKind.SOURCE, + media_type="", + digest=digest, + size_bytes=0, + storage_ref="", + producer="cmis-empty-content-stream", + ) + decision = PolicyDecision.allow( + context.actor.id, + "asset.content_stream.read", + f"asset:{asset.id}", + reason="CMIS document has no content stream; returning an empty compatibility stream.", + ) + return RepresentationContentStream( + representation, + (), + BlobRef( + digest=digest, + size_bytes=0, + storage_key="", + storage_ref="", + adapter="cmis-empty", + media_type=representation.media_type, + ), + decision, + AuditEvent.from_context( + "asset.content_stream.read", + f"asset:{asset.id}", + AuditOutcome.SUCCESS, + context, + policy_decision=decision, + details={"cmis_empty_content_stream": True}, + ), + ) return self.content_service().stream_content(asset_id, context) def cmis_acl( @@ -700,7 +805,8 @@ class ServiceRuntime: if not decision.allowed: raise _cmis_authorization_error(decision, "getObjectParents") if object_id.startswith("cmis:folder:"): - folder_path = _cmis_folder_path(object_id) or "/" + workspace_folder = self._cmis_workspace_folder_by_object_id(access_point_id, object_id) + folder_path = workspace_folder.path if workspace_folder is not None else (_cmis_folder_path(object_id) or "/") if not self._cmis_folder_exists(mapper, context, folder_path): raise NotFoundError( "CMIS folder not found", @@ -710,6 +816,7 @@ class ServiceRuntime: return {"object_id": object_id, "parents": [], "count": 0} parent_path = _path_parent(folder_path) parent = self._cmis_folder_projection(access_point_id, parent_path) + parent["relative_path_segment"] = _path_name(folder_path) return {"object_id": object_id, "parents": [parent], "count": 1} asset_id = _cmis_asset_id(object_id) asset = self.repository.get_asset(asset_id) @@ -724,7 +831,14 @@ class ServiceRuntime: 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)] + child_segment = _path_name(str(explicit_cmis_path)) if explicit_cmis_path else str( + asset.metadata.get("file_name") or asset.title + ) + parents = [] + for path in dict.fromkeys(parent_paths): + parent = self._cmis_folder_projection(access_point_id, path) + parent["relative_path_segment"] = child_segment + parents.append(parent) return {"object_id": mapper.asset_object_id(asset.id), "parents": parents, "count": len(parents)} def cmis_create_folder( @@ -799,9 +913,16 @@ class ServiceRuntime: name = payload.get("name") or properties.get("cmis:name") if not name: raise ValidationError("CMIS document name is required", details={"operation": "createDocument"}) + type_id = properties.get("cmis:objectTypeId", payload.get("type_id", CMISBaseType.DOCUMENT.value)) + asset_type = payload.get("asset_type", "document") + if type_id not in {CMISBaseType.DOCUMENT.value, f"kontextual:{asset_type}"}: + raise ValidationError( + "Unsupported CMIS document type", + details={"operation": "createDocument", "type_id": type_id, "supported": [CMISBaseType.DOCUMENT.value]}, + ) classification = Classification.from_dict( { - "asset_type": payload.get("asset_type", "document"), + "asset_type": asset_type, "sensitivity": payload.get("sensitivity", "internal"), "owner": payload.get("owner"), "topics": payload.get("topics", []), @@ -815,7 +936,7 @@ class ServiceRuntime: representation, _blob, _created = self.content_service().build_representation_from_bytes( asset_id, RepresentationKind.SOURCE, - payload.get("media_type", "text/plain"), + _cmis_media_type(payload.get("media_type", "text/plain")), content, metadata={"cmis": {"operation": "createDocument"}}, ) @@ -829,18 +950,24 @@ class ServiceRuntime: metadata_records=[_metadata_record(item) for item in payload.get("metadata_records", [])], idempotency_key=payload.get("idempotency_key"), ) + metadata = dict(result.asset.metadata) + if "cmis:description" in properties: + metadata["description"] = str(properties.get("cmis:description") or "") + if "cmis:secondaryObjectTypeIds" in properties: + metadata["cmis_secondary_object_type_ids"] = _cmis_value_list(properties.get("cmis:secondaryObjectTypeIds")) 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, + **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), } + if metadata != result.asset.metadata: self.repository.save_asset(replace(result.asset, metadata=metadata)) return self.cmis_object(access_point_id, mapper.asset_object_id(result.asset.id), context) @@ -868,17 +995,38 @@ class ServiceRuntime: decision = mapper.access_point.decide_action(CMISAction.UPDATE_PROPERTIES, context, resource=object_id) if not decision.allowed: raise _cmis_authorization_error(decision, "updateProperties") - asset_id = _cmis_asset_id(object_id) properties = dict(payload.get("properties", payload)) expected = properties.pop("expected_current_version_id", payload.get("expected_current_version_id", None)) + if object_id.startswith("cmis:folder:"): + return self._cmis_update_workspace_folder(mapper, object_id, properties, context) + asset_id = _cmis_asset_id(object_id) + asset_metadata_updates: dict[str, Any] = {} + title_update: str | None = None for key, value in properties.items(): if key.startswith("cmis:"): + if key == "cmis:name": + title_update = str(value).strip() + if not title_update: + raise ValidationError("CMIS name cannot be empty", details={"operation": "updateProperties"}) + asset_metadata_updates["file_name"] = title_update + continue + if key == "cmis:secondaryObjectTypeIds": + asset_metadata_updates["cmis_secondary_object_type_ids"] = _cmis_value_list(value) + continue + if key == "cmis:description": + asset_metadata_updates["description"] = str(value) if value is not None else "" + continue raise ValidationError( "Unsupported CMIS property update", details={ "property": key, "operation": "updateProperties", - "supported": ["kontextual:metadata:"], + "supported": [ + "cmis:name", + "cmis:description", + "cmis:secondaryObjectTypeIds", + "kontextual:metadata:", + ], }, ) self.asset_service().add_metadata_record( @@ -888,6 +1036,17 @@ class ServiceRuntime: expected_current_version_id=expected, ) expected = None + if asset_metadata_updates or title_update is not None: + asset = self.repository.get_asset(asset_id) + metadata = {**asset.metadata, **asset_metadata_updates} + if title_update is not None and asset.metadata.get("cmis_path"): + current_path = _normalize_cmis_path(str(asset.metadata["cmis_path"])) + parent_path = _path_parent(current_path) + metadata["cmis_path"] = _normalize_cmis_path(f"{parent_path}/{title_update}") + metadata["cmis_parent_folder_id"] = "cmis-root" if parent_path == "/" else mapper.folder_object_id(parent_path) + self.repository.save_asset( + replace(asset, title=title_update or asset.title, metadata=metadata) + ) return self.cmis_object(access_point_id, object_id, context) def cmis_set_content_stream( @@ -902,10 +1061,15 @@ class ServiceRuntime: if not decision.allowed: raise _cmis_authorization_error(decision, "setContentStream") asset_id = _cmis_asset_id(object_id) + asset = self.repository.get_asset(asset_id) + if asset.metadata.get("cmis_content_deleted"): + metadata = dict(asset.metadata) + metadata.pop("cmis_content_deleted", None) + self.repository.save_asset(replace(asset, metadata=metadata)) self.content_service().add_representation_from_bytes( asset_id, payload.get("kind", RepresentationKind.SOURCE.value), - payload.get("media_type", "text/plain"), + _cmis_media_type(payload.get("media_type", "text/plain")), payload.get("content", ""), context, expected_current_version_id=payload.get("expected_current_version_id"), @@ -913,6 +1077,84 @@ class ServiceRuntime: ) return self.cmis_object(access_point_id, object_id, context) + def cmis_delete_content_stream( + self, + access_point_id: str, + object_id: str, + context: OperationContext, + ) -> dict[str, Any]: + mapper = self._cmis_mapper(access_point_id) + decision = mapper.access_point.decide_action(CMISAction.SET_CONTENT_STREAM, context, resource=object_id) + if not decision.allowed: + raise _cmis_authorization_error(decision, "deleteContentStream") + asset_id = _cmis_asset_id(object_id) + asset = self.repository.get_asset(asset_id) + if not mapper.access_point.exposes_asset(asset, context): + raise NotFoundError( + "CMIS object not found", + details={"object_id": object_id, "access_point_id": access_point_id}, + ) + self.repository.save_asset(replace(asset, metadata={**asset.metadata, "cmis_content_deleted": True})) + return self.cmis_object(access_point_id, object_id, context) + + def cmis_move_object( + 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.MOVE_OBJECT, context, resource=object_id) + if not decision.allowed: + raise _cmis_authorization_error(decision, "moveObject") + target_folder_id = payload.get("targetFolderId") or payload.get("target_folder_id") + if not target_folder_id: + raise ValidationError("CMIS target folder id is required", details={"operation": "moveObject"}) + target_path = _cmis_folder_path(str(target_folder_id)) or "/" + if not self._cmis_folder_exists(mapper, context, target_path): + raise NotFoundError( + "CMIS target folder not found", + details={"operation": "moveObject", "target_folder_id": target_folder_id}, + ) + source_folder_id = payload.get("sourceFolderId") or payload.get("source_folder_id") + source_path = _cmis_folder_path(str(source_folder_id)) if source_folder_id else None + + if object_id.startswith("cmis:folder:"): + return self._cmis_move_workspace_folder(mapper, object_id, target_path, source_path, context) + + asset_id = _cmis_asset_id(object_id) + asset = self.repository.get_asset(asset_id) + if not mapper.access_point.exposes_asset(asset, context): + raise NotFoundError( + "CMIS object not found", + details={"object_id": object_id, "access_point_id": access_point_id}, + ) + current_path = _normalize_cmis_path(str(asset.metadata.get("cmis_path") or mapper.asset_path(asset))) + current_parent = _path_parent(current_path) + if source_path and source_path != current_parent: + raise ValidationError( + "CMIS source folder does not match current object parent", + details={ + "operation": "moveObject", + "source_folder_id": source_folder_id, + "current_parent": current_parent, + }, + ) + file_name = str(asset.metadata.get("file_name") or _path_name(current_path) or asset.title) + new_path = _normalize_cmis_path(f"{target_path}/{file_name}") + if new_path == current_path: + return self.cmis_object(access_point_id, object_id, context) + self._validate_cmis_path_available(mapper, context, new_path, excluding_asset_id=asset_id) + metadata = { + **asset.metadata, + "cmis_path": new_path, + "cmis_parent_folder_id": "cmis-root" if target_path == "/" else mapper.folder_object_id(target_path), + "file_name": file_name, + } + self.repository.save_asset(replace(asset, metadata=metadata)) + return self.cmis_object(access_point_id, object_id, context) + def representation_content_stream( self, asset_id: str, @@ -937,7 +1179,8 @@ class ServiceRuntime: if not decision.allowed: raise _cmis_authorization_error(decision, "deleteObject") if object_id.startswith("cmis:folder:"): - folder_path = _cmis_folder_path(object_id) or "/" + workspace_folder = self._cmis_workspace_folder_by_object_id(access_point_id, object_id) + folder_path = workspace_folder.path if workspace_folder is not None else (_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) @@ -985,7 +1228,8 @@ class ServiceRuntime: 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) + workspace_folder = self._cmis_workspace_folder_by_object_id(access_point_id, object_id) + folder_path = workspace_folder.path if workspace_folder is not None else _cmis_folder_path(object_id) if folder_path in (None, "/"): raise ValidationError("CMIS root folder cannot be deleteTree target", details={"operation": "deleteTree"}) @@ -1191,7 +1435,7 @@ class ServiceRuntime: projection = mapper.map_asset( asset, context, - representations=self.repository.list_representations(asset_id=asset.id), + representations=self._cmis_asset_representations(asset), versions=self.repository.list_versions(asset.id), relationship_ids=[ f"cmis:relationship:{relationship.relationship_id}" @@ -1214,6 +1458,21 @@ class ServiceRuntime: def _cmis_workspace_folder_map(self, access_point_id: str) -> dict[str, CMISWorkspaceFolder]: return self.cmis_workspace_folders.setdefault(access_point_id, {}) + def _cmis_asset_representations(self, asset) -> list[AssetRepresentation]: + if asset.metadata.get("cmis_content_deleted"): + return [] + return self.repository.list_representations(asset_id=asset.id) + + def _cmis_workspace_folder_by_object_id( + self, + access_point_id: str, + object_id: str, + ) -> CMISWorkspaceFolder | None: + for folder in self._cmis_workspace_folder_map(access_point_id).values(): + if folder.object_id == object_id: + return folder + return None + 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) @@ -1224,6 +1483,208 @@ class ServiceRuntime: return self._cmis_workspace_folder_projection(mapper, folder) return mapper.folder_projection(normalized) + def _validate_cmis_path_available( + self, + mapper: CMISDomainMapper, + context: OperationContext, + path: str, + *, + excluding_asset_id: str | None = None, + excluding_folder_path: str | None = None, + ) -> None: + normalized = _normalize_cmis_path(path) + excluded_folder = _normalize_cmis_path(excluding_folder_path) if excluding_folder_path else None + if normalized != excluded_folder and normalized in self._cmis_workspace_folder_map(mapper.access_point.access_point_id): + raise ValidationError( + "CMIS path already exists", + details={"operation": "moveObject", "path": normalized, "kind": "folder"}, + ) + for asset in self.repository.list_assets(): + if excluding_asset_id and asset.id == excluding_asset_id: + continue + if not mapper.access_point.exposes_asset(asset, context): + continue + if normalized in mapper.asset_paths(asset): + raise ValidationError( + "CMIS path already exists", + details={"operation": "moveObject", "path": normalized, "kind": "document"}, + ) + + def _cmis_update_workspace_folder( + self, + mapper: CMISDomainMapper, + object_id: str, + properties: dict[str, Any], + context: OperationContext, + ) -> dict[str, Any]: + folder = self._cmis_workspace_folder_by_object_id(mapper.access_point.access_point_id, object_id) + if folder is None: + folder_path = _cmis_folder_path(object_id) + if folder_path in (None, "/"): + raise ValidationError("CMIS root folder cannot be updated", details={"operation": "updateProperties"}) + folder = self._cmis_workspace_folder_map(mapper.access_point.access_point_id).get(folder_path) + if folder is None: + raise NotFoundError( + "CMIS folder not found", + details={"operation": "updateProperties", "object_id": object_id}, + ) + + supported = {"cmis:name", "cmis:description", "cmis:secondaryObjectTypeIds"} + unsupported = sorted(key for key in properties if key.startswith("cmis:") and key not in supported) + if unsupported: + raise ValidationError( + "Unsupported CMIS folder property update", + details={"operation": "updateProperties", "unsupported": unsupported, "supported": sorted(supported)}, + ) + + new_name = properties.get("cmis:name") + if new_name is None or str(new_name) == folder.name: + updated = replace(folder, updated_at=utc_now().isoformat()) + self._cmis_workspace_folder_map(mapper.access_point.access_point_id)[folder.path] = updated + return self._cmis_workspace_folder_projection(mapper, updated) + + name = str(new_name).strip() + if not name: + raise ValidationError("CMIS name cannot be empty", details={"operation": "updateProperties"}) + old_path = folder.path + parent_path = _path_parent(old_path) + new_path = _normalize_cmis_path(f"{parent_path}/{name}") + if new_path != old_path: + self._validate_cmis_path_available( + mapper, + context, + new_path, + excluding_folder_path=old_path, + ) + folders = self._cmis_workspace_folder_map(mapper.access_point.access_point_id) + moving = { + path: value + for path, value in folders.items() + if path == old_path or _path_contains(old_path, path) + } + now = utc_now().isoformat() + for path in sorted(moving, key=lambda item: item.count("/"), reverse=True): + del folders[path] + for path, value in sorted(moving.items(), key=lambda item: item[0].count("/")): + suffix = path.removeprefix(old_path) + moved_path = _normalize_cmis_path(f"{new_path}{suffix}") + moved_parent = _path_parent(moved_path) + folders[moved_path] = replace( + value, + path=moved_path, + name=_path_name(moved_path), + parent_id="cmis-root" if moved_parent == "/" else mapper.folder_object_id(moved_parent), + updated_at=now, + ) + + for asset in self.repository.list_assets(): + explicit_path = asset.metadata.get("cmis_path") + if not explicit_path: + continue + asset_path = _normalize_cmis_path(str(explicit_path)) + if not _path_contains(old_path, asset_path): + continue + suffix = asset_path.removeprefix(old_path) + moved_asset_path = _normalize_cmis_path(f"{new_path}{suffix}") + moved_parent = _path_parent(moved_asset_path) + self.repository.save_asset( + replace( + asset, + metadata={ + **asset.metadata, + "cmis_path": moved_asset_path, + "cmis_parent_folder_id": "cmis-root" + if moved_parent == "/" + else mapper.folder_object_id(moved_parent), + }, + ) + ) + return self._cmis_workspace_folder_projection(mapper, folders[new_path]) + + def _cmis_move_workspace_folder( + self, + mapper: CMISDomainMapper, + object_id: str, + target_path: str, + source_path: str | None, + context: OperationContext, + ) -> dict[str, Any]: + workspace_folder = self._cmis_workspace_folder_by_object_id(mapper.access_point.access_point_id, object_id) + folder_path = workspace_folder.path if workspace_folder is not None else _cmis_folder_path(object_id) + if folder_path in (None, "/"): + raise ValidationError("CMIS root folder cannot be moved", details={"operation": "moveObject"}) + folders = self._cmis_workspace_folder_map(mapper.access_point.access_point_id) + folder = folders.get(folder_path) + if folder is None: + raise ValidationError( + "Only adapter-managed CMIS workspace folders can be moved", + details={"operation": "moveObject", "object_id": object_id}, + ) + current_parent = _path_parent(folder_path) + if source_path and source_path != current_parent: + raise ValidationError( + "CMIS source folder does not match current object parent", + details={ + "operation": "moveObject", + "source_folder_id": source_path, + "current_parent": current_parent, + }, + ) + new_path = _normalize_cmis_path(f"{target_path}/{folder.name}") + if new_path == folder_path: + return self._cmis_workspace_folder_projection(mapper, folder) + if _path_contains(folder_path, target_path): + raise ValidationError( + "CMIS folder cannot be moved below itself", + details={"operation": "moveObject", "object_id": object_id, "target_path": target_path}, + ) + self._validate_cmis_path_available(mapper, context, new_path, excluding_folder_path=folder_path) + + now = utc_now().isoformat() + moving = { + path: value + for path, value in folders.items() + if path == folder_path or _path_contains(folder_path, path) + } + for path in sorted(moving, key=lambda item: item.count("/"), reverse=True): + del folders[path] + for path, value in sorted(moving.items(), key=lambda item: item[0].count("/")): + suffix = path.removeprefix(folder_path) + moved_path = _normalize_cmis_path(f"{new_path}{suffix}") + parent_path = _path_parent(moved_path) + folders[moved_path] = replace( + value, + object_id=mapper.folder_object_id(moved_path), + path=moved_path, + name=_path_name(moved_path), + parent_id="cmis-root" if parent_path == "/" else mapper.folder_object_id(parent_path), + updated_at=now, + ) + + for asset in self.repository.list_assets(): + explicit_path = asset.metadata.get("cmis_path") + if not explicit_path: + continue + asset_path = _normalize_cmis_path(str(explicit_path)) + if not _path_contains(folder_path, asset_path): + continue + suffix = asset_path.removeprefix(folder_path) + moved_asset_path = _normalize_cmis_path(f"{new_path}{suffix}") + parent_path = _path_parent(moved_asset_path) + self.repository.save_asset( + replace( + asset, + metadata={ + **asset.metadata, + "cmis_path": moved_asset_path, + "cmis_parent_folder_id": "cmis-root" + if parent_path == "/" + else mapper.folder_object_id(parent_path), + }, + ) + ) + return self._cmis_workspace_folder_projection(mapper, folders[new_path]) + def _cmis_folder_exists( self, mapper: CMISDomainMapper, @@ -1299,7 +1760,7 @@ class ServiceRuntime: projection = mapper.map_asset( asset, context, - representations=self.repository.list_representations(asset_id=asset.id), + representations=self._cmis_asset_representations(asset), versions=self.repository.list_versions(asset.id), relationship_ids=[ f"cmis:relationship:{relationship.relationship_id}" @@ -2726,6 +3187,71 @@ def create_app(runtime: ServiceRuntime | None = None): str(request.url_for("cmis_browser_root", access_point_id=access_point_id)), ) + def browser_include(value: bool | None, *, default: bool = True) -> bool: + return default if value is None else value + + def browser_content_response( + result, + *, + offset: int | None = None, + length: int | None = None, + range_header: str | None = None, + ) -> StreamingResponse: + representation = result.representation + if range_header and offset is None and length is None and range_header.startswith("bytes="): + range_spec = range_header.removeprefix("bytes=").split(",", 1)[0] + start_text, _, end_text = range_spec.partition("-") + if start_text.strip().isdigit(): + offset = int(start_text) + if end_text.strip().isdigit(): + end = int(end_text) + length = max(end - offset + 1, 0) + start = max(offset or 0, 0) + requested_length = None if length is None else max(length, 0) + is_partial = offset is not None or length is not None or bool(range_header) + content_length = max(representation.size_bytes - start, 0) + if requested_length is not None: + content_length = min(content_length, requested_length) + + def chunks(): + skip = start + remaining = requested_length + for chunk in result.chunks: + if skip: + if len(chunk) <= skip: + skip -= len(chunk) + continue + chunk = chunk[skip:] + skip = 0 + if remaining is None: + yield chunk + continue + if remaining <= 0: + break + part = chunk[:remaining] + remaining -= len(part) + if part: + yield part + if remaining <= 0: + break + + headers = { + "Content-Length": str(content_length), + "ETag": representation.digest, + "X-Kontextual-Representation-Id": representation.representation_id, + "X-Kontextual-Storage-Ref": representation.storage_ref or "", + } + if representation.media_type: + headers["Content-Type"] = representation.media_type + if is_partial: + end = start + content_length - 1 if content_length else start + headers["Content-Range"] = f"bytes {start}-{end}/{representation.size_bytes}" + return StreamingResponse( + chunks(), + status_code=206 if is_partial else 200, + headers=headers, + ) + def unsupported_browser_selector(selector: str | None) -> dict[str, Any]: raise ValidationError( "Unsupported CMIS Browser Binding selector", @@ -2764,7 +3290,10 @@ def create_app(runtime: ServiceRuntime | None = None): 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( + "media_type", + _cmis_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) @@ -2816,16 +3345,26 @@ def create_app(runtime: ServiceRuntime | None = None): 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 action in {"updateProperties", "update"}: 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 action == "move": + if not object_id: + raise ValidationError("CMIS object id is required", details={"operation": "moveObject"}) + moved = response(runtime.cmis_move_object, access_point_id, object_id, payload, context) + return cmis_browser_object(moved) + if action in {"setContentStream", "setContent"}: 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) + if action in {"deleteContent", "deleteContentStream"}: + if not object_id: + raise ValidationError("CMIS object id is required", details={"operation": "deleteContentStream"}) + response(runtime.cmis_delete_content_stream, access_point_id, object_id, context) + return response(runtime.cmis_browser_object, access_point_id, object_id, context) raise ValidationError( "Unsupported CMIS Browser Binding action", details={ @@ -2837,7 +3376,12 @@ def create_app(runtime: ServiceRuntime | None = None): "deleteObject", "deleteTree", "updateProperties", + "update", + "move", "setContentStream", + "setContent", + "deleteContent", + "deleteContentStream", ], }, ) @@ -2911,14 +3455,38 @@ def create_app(runtime: ServiceRuntime | None = None): cmisselector: str | None = Query(None), objectId: str | None = Query(None), path: str | None = Query(None), + propertyFilter: str | None = Query(None, alias="filter"), + includeAllowableActions: bool | None = Query(None), + includeACL: bool | None = Query(None), + includePathSegment: bool | None = Query(None), + includeRelativePathSegment: bool | None = Query(None), + offset: int | None = Query(None), + length: int | None = Query(None), + range_header: str | None = Header(None, alias="Range"), 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) + return response( + runtime.cmis_browser_object_by_path, + access_point_id, + path, + context, + property_filter=propertyFilter, + include_allowable_actions=browser_include(includeAllowableActions), + include_acl=browser_include(includeACL, default=False), + ) + return response( + runtime.cmis_browser_object, + access_point_id, + objectId, + context, + property_filter=propertyFilter, + include_allowable_actions=browser_include(includeAllowableActions), + include_acl=browser_include(includeACL, default=False), + ) if cmisselector == "children": return response( runtime.cmis_browser_children, @@ -2927,6 +3495,10 @@ def create_app(runtime: ServiceRuntime | None = None): object_id=objectId, skip_count=skipCount, max_items=maxItems, + property_filter=propertyFilter, + include_allowable_actions=browser_include(includeAllowableActions), + include_acl=browser_include(includeACL, default=False), + include_path_segment=browser_include(includePathSegment), ) if cmisselector == "parent": if not objectId: @@ -2935,32 +3507,164 @@ def create_app(runtime: ServiceRuntime | None = None): if cmisselector == "parents": if not objectId: return [] - return response(runtime.cmis_browser_parents, access_point_id, objectId, context) + return response( + runtime.cmis_browser_parents, + access_point_id, + objectId, + context, + include_relative_path_segment=browser_include(includeRelativePathSegment), + ) 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"] + return response( + runtime.cmis_browser_object_by_path, + access_point_id, + path, + context, + property_filter=propertyFilter, + include_allowable_actions=False, + include_acl=False, + )["properties"] + return response( + runtime.cmis_browser_object, + access_point_id, + objectId, + context, + property_filter=propertyFilter, + include_allowable_actions=False, + include_acl=False, + )["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"] + return response( + runtime.cmis_browser_object_by_path, + access_point_id, + path, + context, + include_allowable_actions=True, + )["allowableActions"] + return response( + runtime.cmis_browser_object, + access_point_id, + objectId, + context, + include_allowable_actions=True, + )["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 browser_content_response(result, offset=offset, length=length, range_header=range_header) + return unsupported_browser_selector(cmisselector) + + @app.get("/cmis/{access_point_id}/browser/root/{object_path:path}", tags=["cmis"]) + def cmis_browser_root_path( + access_point_id: str, + object_path: str, + cmisselector: str | None = Query(None), + objectId: str | None = Query(None), + propertyFilter: str | None = Query(None, alias="filter"), + includeAllowableActions: bool | None = Query(None), + includeACL: bool | None = Query(None), + includePathSegment: bool | None = Query(None), + includeRelativePathSegment: bool | None = Query(None), + offset: int | None = Query(None), + length: int | None = Query(None), + range_header: str | None = Header(None, alias="Range"), + skipCount: int = Query(0), + maxItems: int = Query(100), + context: OperationContext = Depends(context_from_headers), + ) -> Any: + path = _normalize_cmis_path(object_path) + browser_object: dict[str, Any] | None = None + resolution_object: dict[str, Any] | None = None + + def object_by_path() -> dict[str, Any]: + nonlocal browser_object + if browser_object is None: + browser_object = response( + runtime.cmis_browser_object_by_path, + access_point_id, + path, + context, + property_filter=propertyFilter, + include_allowable_actions=browser_include(includeAllowableActions), + include_acl=browser_include(includeACL, default=False), + ) + return browser_object + + def object_for_resolution() -> dict[str, Any]: + nonlocal resolution_object + if resolution_object is None: + resolution_object = response( + runtime.cmis_browser_object_by_path, + access_point_id, + path, + context, + include_allowable_actions=False, + include_acl=False, + ) + return resolution_object + + def resolved_object_id() -> str | None: + if objectId: + return objectId + object_id_property = object_for_resolution().get("properties", {}).get("cmis:objectId", {}) + if isinstance(object_id_property, dict): + return object_id_property.get("value") + return None + + if cmisselector in (None, "", "object"): + return object_by_path() + if cmisselector == "children": + return response( + runtime.cmis_browser_children, + access_point_id, + context, + object_id=resolved_object_id(), + skip_count=skipCount, + max_items=maxItems, + property_filter=propertyFilter, + include_allowable_actions=browser_include(includeAllowableActions), + include_acl=browser_include(includeACL, default=False), + include_path_segment=browser_include(includePathSegment), ) + if cmisselector == "parent": + object_id = resolved_object_id() + if not object_id: + return unsupported_browser_selector(cmisselector) + return response(runtime.cmis_browser_parent, access_point_id, object_id, context) + if cmisselector == "parents": + object_id = resolved_object_id() + if not object_id: + return [] + return response( + runtime.cmis_browser_parents, + access_point_id, + object_id, + context, + include_relative_path_segment=browser_include(includeRelativePathSegment), + ) + if cmisselector == "properties": + return object_by_path()["properties"] + if cmisselector == "allowableActions": + return response( + runtime.cmis_browser_object_by_path, + access_point_id, + path, + context, + include_allowable_actions=True, + )["allowableActions"] + if cmisselector == "policies": + return [] + if cmisselector == "content": + object_id = resolved_object_id() + if not object_id: + return unsupported_browser_selector(cmisselector) + result = response(runtime.cmis_content_stream_bytes, access_point_id, object_id, context) + return browser_content_response(result, offset=offset, length=length, range_header=range_header) return unsupported_browser_selector(cmisselector) @app.post("/cmis/{access_point_id}/browser/root", tags=["cmis"]) @@ -2977,6 +3681,32 @@ def create_app(runtime: ServiceRuntime | None = None): context=context, ) + @app.post("/cmis/{access_point_id}/browser/root/{object_path:path}", tags=["cmis"]) + async def cmis_browser_root_path_action( + access_point_id: str, + object_path: str, + request: Request, + objectId: str | None = Query(None), + context: OperationContext = Depends(context_from_headers), + ) -> Any: + default_object_id = objectId + if not default_object_id: + browser_object = response( + runtime.cmis_browser_object_by_path, + access_point_id, + _normalize_cmis_path(object_path), + context, + ) + object_id_property = browser_object.get("properties", {}).get("cmis:objectId", {}) + if isinstance(object_id_property, dict): + default_object_id = object_id_property.get("value") + return await cmis_browser_post_action( + access_point_id, + request, + default_object_id=default_object_id, + 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) @@ -3018,20 +3748,13 @@ def create_app(runtime: ServiceRuntime | None = None): def cmis_content_stream_bytes( access_point_id: str, object_id: str, + offset: int | None = Query(None), + length: int | None = Query(None), + range_header: str | None = Header(None, alias="Range"), context: OperationContext = Depends(context_from_headers), ) -> Any: result = response(runtime.cmis_content_stream_bytes, access_point_id, object_id, 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 browser_content_response(result, offset=offset, length=length, range_header=range_header) @app.get("/cmis/{access_point_id}/browser/acl/{object_id:path}", tags=["cmis"]) def cmis_acl( @@ -3585,6 +4308,19 @@ def _normalize_cmis_path(path: str) -> str: return "/" + "/".join(parts) +def _cmis_media_type(value: Any) -> str: + media_type = str(value or "application/octet-stream").split(";", 1)[0].strip() + return media_type or "application/octet-stream" + + +def _cmis_value_list(value: Any) -> list[str]: + if value is None or value == "": + return [] + if isinstance(value, (list, tuple, set)): + return [str(item) for item in value if item not in (None, "")] + return [str(value)] + + def _path_parent(path: str) -> str: parts = _normalize_cmis_path(path).strip("/").split("/") if len(parts) <= 1: @@ -3650,16 +4386,21 @@ 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] = {} + property_value_lists: dict[str, list[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 + remainder = key[len("propertyValue[") :] + index, tail = remainder.split("]", 1) + if tail.startswith("["): + property_value_lists.setdefault(index, []).extend(value if isinstance(value, list) else [value]) + else: + property_values[index] = value for index, property_id in property_ids.items(): if property_id: - properties[property_id] = property_values.get(index) + properties[property_id] = property_value_lists.get(index, 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"): diff --git a/src/kontextual_engine/core/cmis.py b/src/kontextual_engine/core/cmis.py index cc4dd97..582f43f 100644 --- a/src/kontextual_engine/core/cmis.py +++ b/src/kontextual_engine/core/cmis.py @@ -58,6 +58,7 @@ class CMISAction(str, Enum): CREATE_FOLDER = "create_folder" CREATE_DOCUMENT = "create_document" UPDATE_PROPERTIES = "update_properties" + MOVE_OBJECT = "move_object" DELETE_OBJECT = "delete_object" DELETE_TREE = "delete_tree" SET_CONTENT_STREAM = "set_content_stream" @@ -89,6 +90,7 @@ ACTION_CAPABILITIES: dict[CMISAction, CMISCapability] = { CMISAction.CREATE_FOLDER: CMISCapability.OBJECT_WRITE, CMISAction.CREATE_DOCUMENT: CMISCapability.OBJECT_WRITE, CMISAction.UPDATE_PROPERTIES: CMISCapability.OBJECT_WRITE, + CMISAction.MOVE_OBJECT: CMISCapability.OBJECT_WRITE, CMISAction.DELETE_OBJECT: CMISCapability.OBJECT_WRITE, CMISAction.DELETE_TREE: CMISCapability.OBJECT_WRITE, CMISAction.SET_CONTENT_STREAM: CMISCapability.CONTENT_STREAM_WRITE, @@ -111,6 +113,7 @@ IMPLEMENTED_CMIS_ACTIONS: frozenset[CMISAction] = frozenset( CMISAction.CREATE_FOLDER, CMISAction.CREATE_DOCUMENT, CMISAction.UPDATE_PROPERTIES, + CMISAction.MOVE_OBJECT, CMISAction.DELETE_OBJECT, CMISAction.DELETE_TREE, CMISAction.SET_CONTENT_STREAM, @@ -120,6 +123,7 @@ MUTATION_ACTIONS = { CMISAction.CREATE_DOCUMENT, CMISAction.CREATE_FOLDER, CMISAction.UPDATE_PROPERTIES, + CMISAction.MOVE_OBJECT, CMISAction.DELETE_OBJECT, CMISAction.DELETE_TREE, CMISAction.SET_CONTENT_STREAM, @@ -884,7 +888,6 @@ class CMISDomainMapper: "cmis:changeToken": asset.current_version_id, "cmis:description": asset.metadata.get("description", asset.title), "cmis:secondaryObjectTypeIds": list(asset.metadata.get("cmis_secondary_object_type_ids", ())), - "cmis:path": self.asset_path(asset), "cmis:contentStreamLength": content_stream.get("length") if content_stream else None, "cmis:contentStreamMimeType": content_stream.get("mime_type") if content_stream else None, "cmis:contentStreamFileName": content_stream.get("file_name") if content_stream else None, @@ -905,6 +908,10 @@ class CMISDomainMapper: compacted.setdefault("cmis:createdBy", "system") compacted.setdefault("cmis:lastModifiedBy", "system") compacted.setdefault("cmis:secondaryObjectTypeIds", []) + compacted.setdefault("cmis:contentStreamLength", None) + compacted.setdefault("cmis:contentStreamMimeType", None) + compacted.setdefault("cmis:contentStreamFileName", None) + compacted.setdefault("cmis:contentStreamId", None) compacted.setdefault("kontextual:owner", "") compacted.setdefault("kontextual:topics", []) compacted.setdefault("kontextual:reviewState", "") @@ -980,6 +987,7 @@ class CMISDomainMapper: CMISAction.GET_RELATIONSHIPS, CMISAction.GET_OBJECT_PARENTS, CMISAction.UPDATE_PROPERTIES, + CMISAction.MOVE_OBJECT, CMISAction.DELETE_OBJECT, CMISAction.SET_CONTENT_STREAM, ] @@ -1209,39 +1217,61 @@ def cmis_browser_type_definition_by_id( raise KeyError(type_id) -def cmis_browser_object(projection: dict[str, Any]) -> dict[str, Any]: - properties = _browser_object_properties(projection) +def cmis_browser_object( + projection: dict[str, Any], + *, + property_filter: str | None = None, + include_allowable_actions: bool = True, + include_acl: bool = True, +) -> dict[str, Any]: + unfiltered_properties = _browser_object_properties(projection) + properties = _filter_browser_properties(unfiltered_properties, property_filter) result = { "properties": { key: cmis_browser_property_value(key, value) for key, value in properties.items() }, "succinctProperties": properties, - "allowableActions": cmis_browser_allowable_actions( - projection.get("allowable_actions", []), - base_type_id=properties.get("cmis:baseTypeId"), - ), } - acl = projection.get("acl") + if include_allowable_actions: + result["allowableActions"] = cmis_browser_allowable_actions( + projection.get("allowable_actions", []), + base_type_id=unfiltered_properties.get("cmis:baseTypeId"), + ) + acl = projection.get("acl") if include_acl else None if isinstance(acl, dict) and acl: result["acl"] = cmis_browser_acl(acl) result["exactACL"] = bool(acl.get("is_exact", True)) return compact_dict(result) -def cmis_browser_object_in_folder_list(children: dict[str, Any]) -> dict[str, Any]: +def cmis_browser_object_in_folder_list( + children: dict[str, Any], + *, + property_filter: str | None = None, + include_allowable_actions: bool = True, + include_acl: bool = True, + include_path_segment: bool = True, +) -> dict[str, Any]: objects = [] for item in children.get("objects", []): + entry = { + "object": cmis_browser_object( + item, + property_filter=property_filter, + include_allowable_actions=include_allowable_actions, + include_acl=include_acl, + ), + } + if include_path_segment: + entry["pathSegment"] = item.get("name") or item.get("object_id") objects.append( - { - "object": cmis_browser_object(item), - "pathSegment": item.get("name") or item.get("object_id"), - } + entry ) return { "objects": objects, "hasMoreItems": bool(children.get("has_more_items", False)), - "numItems": int(children.get("num_items", len(objects))), + "numItems": int(children.get("total_num_items", children.get("num_items", len(objects)))), } @@ -1253,15 +1283,17 @@ def cmis_browser_object_list(objects: list[dict[str, Any]], *, has_more_items: b } -def cmis_browser_parent_list(parents: dict[str, Any]) -> list[dict[str, Any]]: +def cmis_browser_parent_list( + parents: dict[str, Any], + *, + include_relative_path_segment: bool = True, +) -> list[dict[str, Any]]: items = [] for parent in parents.get("parents", []): - items.append( - { - "object": cmis_browser_object(parent), - "relativePathSegment": parent.get("name"), - } - ) + item = {"object": cmis_browser_object(parent)} + if include_relative_path_segment: + item["relativePathSegment"] = parent.get("relative_path_segment") or parent.get("name") + items.append(item) return items @@ -1270,7 +1302,7 @@ def cmis_browser_query_result(query_result: dict[str, Any]) -> dict[str, Any]: return { "results": results, "hasMoreItems": bool(query_result.get("has_more_items", False)), - "numItems": int(query_result.get("num_items", len(results))), + "numItems": int(query_result.get("total_num_items", query_result.get("num_items", len(results)))), } @@ -1351,7 +1383,7 @@ def cmis_browser_allowable_actions( "canGetFolderTree": False, "canGetFolderParent": is_folder and CMISAction.GET_OBJECT_PARENTS.value in native, "canGetRenditions": False, - "canMoveObject": False, + "canMoveObject": CMISAction.MOVE_OBJECT.value in native, "canAddObjectToFolder": False, "canRemoveObjectFromFolder": False, "canCheckOut": False, @@ -1362,7 +1394,7 @@ def cmis_browser_allowable_actions( "canRemovePolicy": False, "canGetAppliedPolicies": False, "canApplyACL": False, - "canDeleteContentStream": False, + "canDeleteContentStream": CMISAction.SET_CONTENT_STREAM.value in native, } @@ -1600,6 +1632,23 @@ def _browser_object_properties(projection: dict[str, Any]) -> dict[str, Any]: return properties +def _filter_browser_properties(properties: dict[str, Any], property_filter: str | None) -> dict[str, Any]: + if property_filter is None: + return properties + requested = { + part.strip() + for part in property_filter.split(",") + if part.strip() + } + if not requested or "*" in requested: + return properties + return { + key: value + for key, value in properties.items() + if key in requested or any(pattern.endswith(":*") and key.startswith(pattern[:-1]) for pattern in requested) + } + + def _browser_property_type(property_id: str, value: Any) -> str: if property_id in { "cmis:objectId", @@ -1677,11 +1726,12 @@ def _property_definitions(base_type_id: CMISBaseType) -> dict[str, dict[str, Any "cmis:lastModificationDate": {"property_type": "datetime", "cardinality": "single", "required": False}, "cmis:changeToken": {"property_type": "string", "cardinality": "single", "required": False}, "cmis:secondaryObjectTypeIds": {"property_type": "id", "cardinality": "multi", "required": False}, - "cmis:path": {"property_type": "string", "cardinality": "single", "required": False}, "cmis:description": {"property_type": "string", "cardinality": "single", "required": False}, "kontextual:sensitivity": {"property_type": "string", "cardinality": "single", "required": False}, "kontextual:lifecycle": {"property_type": "string", "cardinality": "single", "required": False}, } + if base_type_id == CMISBaseType.FOLDER: + definitions["cmis:path"] = {"property_type": "string", "cardinality": "single", "required": False} if base_type_id == CMISBaseType.DOCUMENT: definitions.update( { diff --git a/tests/cmis/test_cmis_browser_binding_api.py b/tests/cmis/test_cmis_browser_binding_api.py index 0f8c7aa..964a3d6 100644 --- a/tests/cmis/test_cmis_browser_binding_api.py +++ b/tests/cmis/test_cmis_browser_binding_api.py @@ -142,7 +142,7 @@ def test_cmis_repository_info_and_type_definitions(cmis_client) -> None: assert root_object["properties"]["kontextual:workspaceFolder"]["value"] is False assert "kontextual:assetId" in browser_type_definition["propertyDefinitions"] assert browser_type_definition["propertyDefinitions"]["kontextual:topics"]["cardinality"] == "multi" - assert "cmis:path" in browser_type_definition["propertyDefinitions"] + assert "cmis:path" not in browser_type_definition["propertyDefinitions"] assert {item["base_type_id"] for item in types["items"]} >= { "cmis:document", "cmis:folder", @@ -236,11 +236,15 @@ def test_cmis_governed_authoring_routes_allow_selected_mutations(cmis_client) -> ) streamed = cmis_client.post( "/cmis/governed-authoring/browser/object/cmis:asset:asset-api-authored/content", - json={"content": "# Updated", "media_type": "text/markdown"}, + json={"content": "# Updated", "media_type": "text/plain; charset=utf-8"}, ) byte_stream = cmis_client.get( "/cmis/governed-authoring/browser/content-bytes/cmis:asset:asset-api-authored", ) + byte_range = cmis_client.get( + "/cmis/governed-authoring/browser/content-bytes/cmis:asset:asset-api-authored", + params={"offset": 2, "length": 4}, + ) deleted = cmis_client.post( "/cmis/governed-authoring/browser/object/cmis:asset:asset-api-authored/delete", json={}, @@ -248,12 +252,80 @@ def test_cmis_governed_authoring_routes_allow_selected_mutations(cmis_client) -> assert created.status_code == 200 assert updated.json()["properties"]["kontextual:metadata:status"] == "draft" - assert streamed.json()["content_stream"]["mime_type"] == "text/markdown" + assert streamed.json()["content_stream"]["mime_type"] == "text/plain" assert byte_stream.content == b"# Updated" + assert byte_stream.headers["content-type"] == "text/plain" assert byte_stream.headers["etag"].startswith("sha256:") + assert byte_range.content == b"Upda" + assert byte_range.headers["content-length"] == "4" assert deleted.json()["lifecycle"] == "delete_requested" +def test_cmis_browser_binding_create_document_validates_type_and_secondary_ids(cmis_client) -> None: + invalid = cmis_client.post( + "/cmis/compat-tck/browser/root", + data={ + "cmisaction": "createDocument", + "propertyId[0]": "cmis:objectTypeId", + "propertyValue[0]": "cmis:folder", + "propertyId[1]": "cmis:name", + "propertyValue[1]": "Invalid Document", + }, + ) + created = cmis_client.post( + "/cmis/compat-tck/browser/root", + data={ + "cmisaction": "createDocument", + "propertyId[0]": "cmis:objectTypeId", + "propertyValue[0]": "cmis:document", + "propertyId[1]": "cmis:name", + "propertyValue[1]": "Secondary Document", + "propertyId[2]": "cmis:secondaryObjectTypeIds", + "propertyValue[2][0]": "kontextual:secondary", + }, + files={"content": ("secondary.txt", b"Secondary content", "text/plain")}, + ) + + assert invalid.status_code == 422 + assert invalid.json()["detail"]["details"]["type_id"] == "cmis:folder" + assert created.status_code == 200 + assert created.json()["properties"]["cmis:secondaryObjectTypeIds"]["value"] == ["kontextual:secondary"] + + +def test_cmis_browser_binding_document_without_content_streams_empty_compatibility_body(cmis_client) -> None: + folder = cmis_client.post( + "/cmis/compat-tck/browser/root", + data={ + "cmisaction": "createFolder", + "propertyId[0]": "cmis:objectTypeId", + "propertyValue[0]": "cmis:folder", + "propertyId[1]": "cmis:name", + "propertyValue[1]": "No Content Folder", + }, + ).json() + document = cmis_client.post( + "/cmis/compat-tck/browser/root", + data={ + "cmisaction": "createDocument", + "objectId": folder["properties"]["cmis:objectId"]["value"], + "propertyId[0]": "cmis:objectTypeId", + "propertyValue[0]": "cmis:document", + "propertyId[1]": "cmis:name", + "propertyValue[1]": "No Content Document", + }, + ).json() + object_id = document["properties"]["cmis:objectId"]["value"] + content = cmis_client.get( + "/cmis/compat-tck/browser/root", + params={"cmisselector": "content", "objectId": object_id}, + ) + + assert document["properties"]["cmis:contentStreamMimeType"]["value"] is None + assert content.status_code == 200 + assert content.content == b"" + assert content.headers["content-length"] == "0" + + def test_cmis_browser_binding_create_folder_action_creates_workspace_folder(cmis_client) -> None: created = cmis_client.post( "/cmis/compat-tck/browser/root", @@ -291,14 +363,77 @@ def test_cmis_browser_binding_create_folder_action_creates_workspace_folder(cmis }, files={"content": ("multipart.txt", b"Multipart content", "text/plain")}, ) - document_path = document.json()["properties"]["cmis:path"]["value"] + document_id = document.json()["properties"]["cmis:objectId"]["value"] + document_path = "/Action Workspace/Multipart Document" fetched_document_by_path = cmis_client.get( "/cmis/compat-tck/browser/root", params={"cmisselector": "object", "path": document_path}, ).json() + fetched_document_by_url_path = cmis_client.get( + "/cmis/compat-tck/browser/root/Action%20Workspace/Multipart%20Document", + params={"cmisselector": "object"}, + ).json() + updated_document_by_alias = cmis_client.post( + "/cmis/compat-tck/browser/root/Action%20Workspace/Multipart%20Document", + data={ + "cmisaction": "update", + "propertyId[0]": "cmis:secondaryObjectTypeIds", + "propertyValue[0][0]": "kontextual:secondary", + }, + ) + destination = cmis_client.post( + "/cmis/compat-tck/browser/root", + data={ + "cmisaction": "createFolder", + "propertyId[0]": "cmis:objectTypeId", + "propertyValue[0]": "cmis:folder", + "propertyId[1]": "cmis:name", + "propertyValue[1]": "Destination", + }, + ) + destination_id = destination.json()["properties"]["cmis:objectId"]["value"] + moved_document = cmis_client.post( + "/cmis/compat-tck/browser/root", + data={ + "cmisaction": "move", + "objectId": document_id, + "sourceFolderId": folder_id, + "targetFolderId": destination_id, + }, + ) + moved_path = "/Destination/Multipart Document" + fetched_old_document_path_after_move = cmis_client.get( + "/cmis/compat-tck/browser/root", + params={"cmisselector": "object", "path": document_path}, + ) + fetched_moved_document_by_path = cmis_client.get( + "/cmis/compat-tck/browser/root", + params={"cmisselector": "object", "path": moved_path}, + ).json() document_parents = cmis_client.get( "/cmis/compat-tck/browser/root", - params={"cmisselector": "parents", "objectId": document.json()["properties"]["cmis:objectId"]["value"]}, + params={"cmisselector": "parents", "objectId": document_id}, + ).json() + filtered_document = cmis_client.get( + "/cmis/compat-tck/browser/root", + params={ + "cmisselector": "object", + "path": moved_path, + "filter": "cmis:objectId,cmis:name", + "includeAllowableActions": False, + "includeACL": False, + }, + ).json() + filtered_children = cmis_client.get( + "/cmis/compat-tck/browser/root", + params={ + "cmisselector": "children", + "objectId": destination_id, + "filter": "cmis:objectId,cmis:name", + "includeAllowableActions": False, + "includeACL": False, + "includePathSegment": False, + }, ).json() fetched_folder_by_path = cmis_client.get( "/cmis/compat-tck/browser/root", @@ -334,9 +469,26 @@ def test_cmis_browser_binding_create_folder_action_creates_workspace_folder(cmis assert document.json()["properties"]["cmis:isLatestVersion"]["value"] is True assert document.json()["properties"]["cmis:secondaryObjectTypeIds"]["value"] == [] assert document.json()["allowableActions"]["canGetFolderParent"] is False + assert document.json()["allowableActions"]["canMoveObject"] is True assert document_path == "/Action Workspace/Multipart Document" assert fetched_document_by_path["properties"]["cmis:name"]["value"] == "Multipart Document" - assert document_parents[0]["object"]["properties"]["cmis:path"]["value"] == "/Action Workspace" + assert fetched_document_by_url_path["properties"]["cmis:name"]["value"] == "Multipart Document" + assert updated_document_by_alias.status_code == 200 + assert updated_document_by_alias.json()["properties"]["cmis:secondaryObjectTypeIds"]["value"] == [ + "kontextual:secondary" + ] + assert destination.status_code == 200 + assert moved_document.status_code == 200 + assert moved_path == "/Destination/Multipart Document" + assert fetched_old_document_path_after_move.status_code == 404 + assert fetched_moved_document_by_path["properties"]["cmis:name"]["value"] == "Multipart Document" + assert document_parents[0]["object"]["properties"]["cmis:path"]["value"] == "/Destination" + assert document_parents[0]["relativePathSegment"] == "Multipart Document" + assert set(filtered_document["properties"]) == {"cmis:objectId", "cmis:name"} + assert "allowableActions" not in filtered_document + assert "pathSegment" not in filtered_children["objects"][0] + assert set(filtered_children["objects"][0]["object"]["properties"]) == {"cmis:objectId", "cmis:name"} + assert "allowableActions" not in filtered_children["objects"][0]["object"] assert fetched_folder_by_path["properties"]["cmis:objectId"]["value"] == folder_id assert deleted_tree.json()["failedToDelete"] == [] assert all( @@ -368,9 +520,14 @@ def test_cmis_readonly_route_rejects_mutation(cmis_client) -> None: def test_cmis_rejects_unsupported_standard_property_update_with_diagnostics(cmis_client) -> None: response = cmis_client.post( "/cmis/governed-authoring/browser/object/cmis:asset:asset-source/properties", - json={"properties": {"cmis:name": "Renamed"}}, + json={"properties": {"cmis:objectTypeId": "cmis:folder"}}, ) assert response.status_code == 422 - assert response.json()["detail"]["details"]["property"] == "cmis:name" - assert response.json()["detail"]["details"]["supported"] == ["kontextual:metadata:"] + assert response.json()["detail"]["details"]["property"] == "cmis:objectTypeId" + assert response.json()["detail"]["details"]["supported"] == [ + "cmis:name", + "cmis:description", + "cmis:secondaryObjectTypeIds", + "kontextual:metadata:", + ] diff --git a/tests/cmis/test_cmis_runtime_browser_binding.py b/tests/cmis/test_cmis_runtime_browser_binding.py index 6991d27..837a8d1 100644 --- a/tests/cmis/test_cmis_runtime_browser_binding.py +++ b/tests/cmis/test_cmis_runtime_browser_binding.py @@ -215,6 +215,24 @@ def test_runtime_cmis_compat_profile_supports_workspace_folder_lifecycle(cmis_ru folder_children = runtime.cmis_children("compat-tck", context, folder_id=folder_object_id) document_by_path = runtime.cmis_object_by_path("compat-tck", "/TCK Workspace/Workspace Document", context) document_parents = runtime.cmis_object_parents("compat-tck", document["object_id"], context) + browser_document_parents = runtime.cmis_browser_parents("compat-tck", document["object_id"], context) + filtered_document = runtime.cmis_browser_object( + "compat-tck", + document["object_id"], + context, + property_filter="cmis:objectId,cmis:name", + include_allowable_actions=False, + include_acl=False, + ) + filtered_children = runtime.cmis_browser_children( + "compat-tck", + context, + object_id=folder_object_id, + property_filter="cmis:objectId,cmis:name", + include_allowable_actions=False, + include_acl=False, + include_path_segment=False, + ) assert folder["path"] == "/TCK Workspace" assert folder["properties"]["kontextual:workspaceFolder"] is True @@ -222,11 +240,17 @@ def test_runtime_cmis_compat_profile_supports_workspace_folder_lifecycle(cmis_ru assert fetched["properties"]["cmis:path"] == "/TCK Workspace" assert parents["parents"][0]["object_id"] == "cmis-root" assert document["path"] == "/TCK Workspace/Workspace Document" - assert document["properties"]["cmis:path"] == "/TCK Workspace/Workspace Document" + assert "cmis:path" not in document["properties"] assert document_by_path["object_id"] == document["object_id"] assert document_parents["count"] == 1 assert document_parents["parents"][0]["properties"]["cmis:path"] == "/TCK Workspace" + assert browser_document_parents[0]["relativePathSegment"] == "Workspace Document" assert document["object_id"] in {item["object_id"] for item in folder_children["objects"]} + assert set(filtered_document["properties"]) == {"cmis:objectId", "cmis:name"} + assert "allowableActions" not in filtered_document + assert "pathSegment" not in filtered_children["objects"][0] + assert set(filtered_children["objects"][0]["object"]["properties"]) == {"cmis:objectId", "cmis:name"} + assert "allowableActions" not in filtered_children["objects"][0]["object"] with pytest.raises(Exception) as exc_info: runtime.cmis_delete_object("compat-tck", folder_object_id, {}, context) @@ -242,6 +266,31 @@ def test_runtime_cmis_compat_profile_supports_workspace_folder_lifecycle(cmis_ru assert "CMIS folder not found" in str(exc_info.value) +def test_runtime_cmis_workspace_folder_rename_keeps_object_id_stable(cmis_runtime) -> None: + runtime, context = cmis_runtime + + folder = runtime.cmis_create_folder( + "compat-tck", + {"name": "Rename Source", "properties": {"cmis:objectTypeId": "cmis:folder"}}, + context, + ) + renamed = runtime.cmis_update_properties( + "compat-tck", + folder["object_id"], + {"properties": {"cmis:name": "Rename Target"}}, + context, + ) + fetched_by_old_id = runtime.cmis_object("compat-tck", folder["object_id"], context) + fetched_by_new_path = runtime.cmis_object_by_path("compat-tck", "/Rename Target", context) + deleted = runtime.cmis_delete_object("compat-tck", folder["object_id"], {}, context) + + assert renamed["object_id"] == folder["object_id"] + assert renamed["path"] == "/Rename Target" + assert fetched_by_old_id["properties"]["cmis:name"] == "Rename Target" + assert fetched_by_new_path["object_id"] == folder["object_id"] + assert deleted["deleted"] is True + + def test_runtime_cmis_rejects_unsupported_standard_property_updates(cmis_runtime) -> None: runtime, context = cmis_runtime @@ -249,7 +298,7 @@ def test_runtime_cmis_rejects_unsupported_standard_property_updates(cmis_runtime runtime.cmis_update_properties( "governed-authoring", "cmis:asset:asset-runtime-source", - {"properties": {"cmis:name": "Renamed"}}, + {"properties": {"cmis:objectTypeId": "cmis:folder"}}, context, ) diff --git a/workplans/KONT-WP-0014-cmis-object-content-maturity.md b/workplans/KONT-WP-0014-cmis-object-content-maturity.md index 964500d..5ea229d 100644 --- a/workplans/KONT-WP-0014-cmis-object-content-maturity.md +++ b/workplans/KONT-WP-0014-cmis-object-content-maturity.md @@ -127,6 +127,75 @@ Current external frontier: These are follow-up maturity items rather than the original folder-creatable blocker. +## Implementation Evidence - 2026-05-08T15:33:16Z + +Evidence file: + +- `docs/cmis-opencmis-tck-wp0014-evidence-2026-05-08T153316Z.md` + +Implemented in this pass: + +- Browser Binding action aliases and path-addressed routes: + `cmisaction=update`, `cmisaction=move`, and `/browser/root/{path}`. +- MIME normalization and explicit content stream `Content-Type` headers. +- Metadata-backed standard property support for `cmis:name`, + `cmis:description`, and `cmis:secondaryObjectTypeIds`. +- Create-time secondary type id projection and invalid document type rejection. +- Removal of non-standard document `cmis:path` while preserving folder paths. + +Latest verification: + +- Internal: `.venv/bin/python -m pytest tests/cmis -q` -> `48 passed`. +- OpenCMIS: `run-20260508T153316Z` in + `/tmp/open-cmis-tck-kontextual-wp14-20260508T153146Z`. +- Maturity score: `19.05`; coverage remains `2/9` groups. +- `repository-type` is now partial/warning; `object-content` remains + infrastructure-blocked by concrete CRUD/content edge cases. + +Current external frontier: + +- `getObjectByPath` path-segment failures in child checks. +- No-content document content-stream semantics. +- Operation-context/property filter trimming, especially folder `cmis:path`. +- `bulkUpdate`, `deleteContent`, and change-token unsupported-action handling. + +## Implementation Evidence - 2026-05-08T16:43:34Z + +Evidence file: + +- `docs/cmis-opencmis-tck-wp0014-evidence-2026-05-08T164334Z.md` + +Implemented in this pass: + +- Correct `relativePathSegment` behavior for document parents. +- Browser Binding operation-context trimming for property filters, allowable + actions, ACLs, and path segments. +- Total-count `numItems` semantics for Browser Binding children. +- Stable adapter-managed folder object IDs across folder rename/update. +- Nullable no-content document stream properties and empty compatibility streams. +- Range-aware content responses with sliced bodies, `206`, and `Content-Range`. +- `setContent` and `deleteContent` Browser Binding action aliases. + +Latest verification: + +- Internal focused CMIS tests: `20 passed`. +- Full suite: `160 passed, 14 skipped`. +- OpenCMIS: `run-20260508T164334Z` in + `/tmp/open-cmis-tck-kontextual-wp14-20260508T1643Z`. +- `repository-type`: `38 pass`, `2 info`, `1 skipped`, `1 warning`. +- `object-content`: `12 info`, `5 skipped`, `3 warning`, `3 fail`, + `3 infrastructure_error`. + +Current external frontier: + +- CMIS-specific exception mapping for invalid type operations. +- `bulkUpdateProperties` remains unsupported. +- `deleteContentStream` needs stronger representation-removal/tombstone + semantics. +- Change-token conflict behavior is not implemented. +- `createDocumentFromSource`/copy remains unsupported. +- Offset-zero range requests are still marked partial. + ## D14.1 - Define CMIS maturity boundary and TCK profile semantics ```task @@ -187,7 +256,7 @@ Acceptance: ```task id: KONT-WP-0014-T004 -status: in_progress +status: done priority: high state_hub_task_id: "f9323c25-4d81-42cd-b7e6-e40d7e0487cd" ``` @@ -208,7 +277,7 @@ Acceptance: ```task id: KONT-WP-0014-T005 -status: todo +status: in_progress priority: medium state_hub_task_id: "5feb6db8-24eb-4c20-8c3e-d530f396ef6a" ``` @@ -223,6 +292,13 @@ Acceptance: naturally through blob services or explicitly advertised as unsupported. - Blob deduplication and digest verification remain intact. +Progress: + +- Done for normal reads, no-content compatibility streams, partial body slicing, + `Content-Length`, `Content-Type`, `ETag`, and `Content-Range`. +- Remaining: clean `deleteContentStream` semantics and offset-zero range + classification. + ## D14.6 - Add natural navigation and query depth ```task @@ -242,11 +318,18 @@ Acceptance: indexed/available metadata fields, or returns precise unsupported diagnostics. - Capability flags are updated only for behavior that is actually supported. +Progress: + +- Done for `getObjectByPath`, `getFolderParent`, parent path segments, and + folder rename path stability. +- Remaining: query predicate/order depth and any deliberate descendants/tree + expansion. + ## D14.7 - Polish read-side relationships, ACL discovery, and change tokens ```task id: KONT-WP-0014-T007 -status: todo +status: in_progress priority: medium state_hub_task_id: "60f7b222-6eea-4add-822d-3439d568d4f6" ``` @@ -261,6 +344,11 @@ Acceptance: - ACL mutation, policy mutation, PWC/versioning, and type mutability remain unsupported unless a later task explicitly changes scope. +Progress: + +- Started by isolating OpenCMIS change-token failures as the main T007 maturity + gap. Relationship and ACL discovery were not expanded in this pass. + ## D14.8 - Expand OpenCMIS assessment and update maturity scorecard ```task