From 956b199c7985e5d7292c705ee9f74157cbad81ea Mon Sep 17 00:00:00 2001 From: Dan Wolfson Date: Mon, 19 Oct 2026 09:47:22 -0500 Subject: [PATCH] fix: catalog-target request bodies (ISSUE-156) and SLO map fields (ISSUE-157) ISSUE-156: AutomatedCuration.add_catalog_target and update_catalog_target posted a flat "CatalogTargetRequestBody", a class Egeria does not have, so the platform refused them. They now send NewRelationshipRequestBody / UpdateRelationshipRequestBody with CatalogTargetProperties (matching AssetMakerResource), keeping the existing signatures. template_properties maps to Egeria's "templates" field, which the old body also misnamed. ISSUE-157: set_gov_prop_body defaulted entitlements, obligations and restrictions to [], but Egeria types them as Map (TermsAndConditionsProperties and subtypes such as ServiceLevelObjectiveProperties), so Create Service Level Objective was refused. They now default to None. Micro-tests: tests/micro-tests/test_issue_156_157_catalog_target_slo.py; full micro-test suite passes in a clean python:3.13-slim container. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Dan Wolfson --- .../md_processing_utils/common_md_utils.py | 8 +- pyegeria/omvs/automated_curation.py | 55 ++++++---- .../test_issue_156_157_catalog_target_slo.py | 101 ++++++++++++++++++ 3 files changed, 142 insertions(+), 22 deletions(-) create mode 100644 tests/micro-tests/test_issue_156_157_catalog_target_slo.py diff --git a/md_processing/md_processing_utils/common_md_utils.py b/md_processing/md_processing_utils/common_md_utils.py index 695dbd45..91ce6949 100644 --- a/md_processing/md_processing_utils/common_md_utils.py +++ b/md_processing/md_processing_utils/common_md_utils.py @@ -605,9 +605,11 @@ def set_gov_prop_body(object_type: str, qualified_name: str, attributes: dict)-> prop_bod["implications"] = attributes.get('Implications', {}).get('value', []) prop_bod["outcomes"] = attributes.get('Outcomes', {}).get('value', []) prop_bod["results"] = attributes.get('Results', {}).get('value', []) or [] - prop_bod["entitlements"] = attributes.get('Entitlements', {}).get('value', []) - prop_bod["obligations"] = attributes.get('Obligations', {}).get('value', []) - prop_bod["restrictions"] = attributes.get('Restrictions', {}).get('value', []) + # ISSUE-157: Egeria types these as Map (TermsAndConditionsProperties and its + # subtypes, e.g. ServiceLevelObjectiveProperties); a [] default made the server refuse the body. + prop_bod["entitlements"] = attributes.get('Entitlements', {}).get('value', None) + prop_bod["obligations"] = attributes.get('Obligations', {}).get('value', None) + prop_bod["restrictions"] = attributes.get('Restrictions', {}).get('value', None) prop_bod["usage"] = attributes.get('Usage', {}).get('value', None) prop_bod["effectiveFrom"] = attributes.get('Effective From', {}).get('value', None) prop_bod["effectiveTo"] = attributes.get('Effective To', {}).get('value', None) diff --git a/pyegeria/omvs/automated_curation.py b/pyegeria/omvs/automated_curation.py index f1a7eacc..4a8f4084 100644 --- a/pyegeria/omvs/automated_curation.py +++ b/pyegeria/omvs/automated_curation.py @@ -3975,6 +3975,25 @@ def get_catalog_target( ) return response + @staticmethod + def _catalog_target_properties(catalog_target_name: str, connection_name: Optional[str], + metadata_src_qual_name: Optional[str], config_properties: Optional[dict], + template_properties: Optional[dict], permitted_sync: Optional[str], + delete_method: Optional[str]) -> dict: + """Build the CatalogTargetProperties for add/update_catalog_target, omitting unset fields. + `template_properties` maps to Egeria's `templates` (template name -> template GUID).""" + props = { + "class": "CatalogTargetProperties", + "catalogTargetName": catalog_target_name, + "connectionName": connection_name, + "metadataSourceQualifiedName": metadata_src_qual_name, + "configurationProperties": config_properties, + "templates": template_properties, + "permittedSynchronization": permitted_sync, + "deleteMethod": delete_method, + } + return {k: v for k, v in props.items() if v is not None} + async def _async_add_catalog_target( self, integ_connector_guid: str, @@ -4032,19 +4051,18 @@ async def _async_add_catalog_target( url = f"{self.ref_curation_command_base}/integration-connectors/{integ_connector_guid}/catalog-targets/{metadata_element_guid}" + # ISSUE-156: Egeria takes NewRelationshipRequestBody with CatalogTargetProperties + # (AssetMakerResource.addCatalogTarget); there is no CatalogTargetRequestBody class. body = { - "class": "CatalogTargetRequestBody", - "catalogTargetName": catalog_target_name, - "connectionName": connection_name, - "metadataSourceQualifiedName": metadata_src_qual_name, - "configurationProperties": config_properties, - "templateProperties": template_properties, - "permittedSynchronization": permitted_sync, - "deleteMethod": delete_method, + "class": "NewRelationshipRequestBody", + "properties": self._catalog_target_properties( + catalog_target_name, connection_name, metadata_src_qual_name, + config_properties, template_properties, permitted_sync, delete_method, + ), } - response = await self._async_make_request("POST", url, body) - return response.json().get("guid", "No Guid returned") + guid = await self._async_new_relationship_request(url, ["CatalogTargetProperties"], body) + return guid or "No Guid returned" def add_catalog_target( self, @@ -4155,18 +4173,17 @@ async def _async_update_catalog_target( url = f"{self.ref_curation_command_base}/catalog-targets/{relationship_guid}/update" + # ISSUE-156: Egeria takes UpdateRelationshipRequestBody with CatalogTargetProperties + # (AssetMakerResource.updateCatalogTarget). body = { - "class": "CatalogTargetRequestBody", - "catalogTargetName": catalog_target_name, - "connectionName": connection_name, - "metadataSourceQualifiedName": metadata_src_qual_name, - "configurationProperties": config_properties, - "templateProperties": template_properties, - "permittedSynchronization": permitted_sync, - "deleteMethod": delete_method, + "class": "UpdateRelationshipRequestBody", + "properties": self._catalog_target_properties( + catalog_target_name, connection_name, metadata_src_qual_name, + config_properties, template_properties, permitted_sync, delete_method, + ), } - await self._async_make_request("POST", url, body) + await self._async_update_relationship_request(url, ["CatalogTargetProperties"], body) def update_catalog_target( self, diff --git a/tests/micro-tests/test_issue_156_157_catalog_target_slo.py b/tests/micro-tests/test_issue_156_157_catalog_target_slo.py new file mode 100644 index 00000000..f9d1348c --- /dev/null +++ b/tests/micro-tests/test_issue_156_157_catalog_target_slo.py @@ -0,0 +1,101 @@ +""" +ISSUE-156: AutomatedCuration.add/update_catalog_target posted a flat +"CatalogTargetRequestBody", a class Egeria does not have; the endpoints take +New/UpdateRelationshipRequestBody with CatalogTargetProperties. +ISSUE-157: set_gov_prop_body defaulted entitlements/obligations/restrictions +to [], which Egeria cannot read into its Map fields, so +Create Service Level Objective was refused. +No live server: the HTTP call is captured and the body asserted. +""" +import json + +import pytest + +from pyegeria.omvs.automated_curation import AutomatedCuration +from md_processing.md_processing_utils.common_md_utils import set_gov_prop_body + +CONNECTOR = "11111111-1111-1111-1111-111111111111" +ELEMENT = "22222222-2222-2222-2222-222222222222" +REL = "33333333-3333-3333-3333-333333333333" + + +class _Response: + def __init__(self, payload): + self._payload = payload + + def json(self): + return self._payload + + +def _client(calls: list, monkeypatch) -> AutomatedCuration: + monkeypatch.setattr(AutomatedCuration, "check_connection", lambda self: "test") + client = AutomatedCuration("view-server", "https://localhost:1", "user", "pwd") + + async def fake_make_request(method, url, payload_=None, *args, **kwargs): + calls.append({"method": method, "url": url, + "body": json.loads(payload_) if isinstance(payload_, str) else payload_}) + return _Response({"class": "GUIDResponse", "guid": "rel-guid"}) + + client._async_make_request = fake_make_request + return client + + +@pytest.mark.asyncio +async def test_add_catalog_target_sends_new_relationship_request_body(monkeypatch): + calls = [] + client = _client(calls, monkeypatch) + guid = await client._async_add_catalog_target( + CONNECTOR, ELEMENT, "target-1", connection_name="conn", + config_properties={"a": "b"}, template_properties={"tmpl": "tmpl-guid"}) + + assert guid == "rel-guid" + call = calls[0] + assert call["method"] == "POST" + assert call["url"].endswith( + f"/automated-curation/integration-connectors/{CONNECTOR}/catalog-targets/{ELEMENT}") + body = call["body"] + assert body["class"] == "NewRelationshipRequestBody" + assert "catalogTargetName" not in body # no flat properties at the top level + assert body["properties"] == { + "class": "CatalogTargetProperties", + "catalogTargetName": "target-1", + "connectionName": "conn", + "configurationProperties": {"a": "b"}, + "templates": {"tmpl": "tmpl-guid"}, + "permittedSynchronization": "BOTH_DIRECTIONS", + "deleteMethod": "ARCHIVE", + } + + +@pytest.mark.asyncio +async def test_update_catalog_target_sends_update_relationship_request_body(monkeypatch): + calls = [] + client = _client(calls, monkeypatch) + await client._async_update_catalog_target(REL, "target-2", permitted_sync="FROM_THIRD_PARTY") + + call = calls[0] + assert call["url"].endswith(f"/automated-curation/catalog-targets/{REL}/update") + body = call["body"] + assert body["class"] == "UpdateRelationshipRequestBody" + assert body["properties"]["class"] == "CatalogTargetProperties" + assert body["properties"]["catalogTargetName"] == "target-2" + assert body["properties"]["permittedSynchronization"] == "FROM_THIRD_PARTY" + assert "connectionName" not in body["properties"] + + +def _attrs(**values): + return {k: {"value": v} for k, v in values.items()} + + +def test_service_level_objective_body_has_no_list_valued_map_fields(): + body = set_gov_prop_body("Service Level Objective", "SLO::pyeg-test", + _attrs(**{"Display Name": "Within an hour"})) + for field in ("entitlements", "obligations", "restrictions"): + assert body.get(field) is None, f"{field} must not default to a list" + + +def test_terms_and_conditions_maps_are_still_passed_through(): + body = set_gov_prop_body("Terms And Conditions", "TC::pyeg-test", + _attrs(**{"Display Name": "T&C", "Entitlements": {"read": "yes"}})) + assert body["entitlements"] == {"read": "yes"} + assert body.get("obligations") is None