diff --git a/common/vcon.py b/common/vcon.py index 7df19b4..dc45565 100644 --- a/common/vcon.py +++ b/common/vcon.py @@ -131,6 +131,14 @@ def add_tag(self, tag_name, tag_value): # back as spec-correct ``encoding=json`` + stringified list, per # draft-ietf-vcon-vcon-core-02 ยง2.3.2 (body is always a String). tags = self.decoded_body(tags_attachment) or [] + if isinstance(tags, dict): + # A producer wrote purpose="tags" with a dict body (e.g. the SIPREC + # adapter's provenance metadata). Flatten to the tag convention's + # "name:value" list so get_tag reads it and append never raises. + # (CON-737) + tags = [f"{k}:{v}" for k, v in tags.items()] + elif not isinstance(tags, list): + tags = [tags] tags.append(f"{tag_name}:{tag_value}") tags_attachment["body"] = json.dumps(tags) tags_attachment["encoding"] = "json" diff --git a/conserver/links/tag/__init__.py b/conserver/links/tag/__init__.py index 824cc6e..cf641a4 100644 --- a/conserver/links/tag/__init__.py +++ b/conserver/links/tag/__init__.py @@ -16,8 +16,21 @@ def run( vcon_redis = VconRedis() vCon = vcon_redis.get_vcon(vcon_uuid) - for tag in opts.get("tags", []): - vCon.add_tag(tag_name=tag, tag_value=tag) + tags = opts.get("tags", []) + if isinstance(tags, dict): + # dict-form options: {name: value} (CON-737) + pairs = list(tags.items()) + else: + # list-form options: "name:value" strings, or bare names. + pairs = [] + for tag in tags: + if isinstance(tag, str) and ":" in tag: + name, value = tag.split(":", 1) + else: + name = value = tag + pairs.append((name, value)) + for name, value in pairs: + vCon.add_tag(tag_name=name, tag_value=value) vcon_redis.store_vcon(vCon) # Return the vcon_uuid down the chain. diff --git a/conserver/links/tag/test_tag.py b/conserver/links/tag/test_tag.py index ffff22f..dc57b5f 100644 --- a/conserver/links/tag/test_tag.py +++ b/conserver/links/tag/test_tag.py @@ -1,3 +1,4 @@ +import json from unittest.mock import patch from vcon import Vcon @@ -44,3 +45,48 @@ def test_run_handles_empty_tag_list(mock_vcon_redis): assert result == "test-uuid" assert vcon.get_tag("iron") is None mock_instance.store_vcon.assert_called_once_with(vcon) + + +@patch("links.tag.VconRedis") +def test_run_applies_name_value_string_tags(mock_vcon_redis): + # "name:value" options must set name -> value, not "tag:tag". (CON-737) + vcon = Vcon.build_new() + mock_instance = mock_vcon_redis.return_value + mock_instance.get_vcon.return_value = vcon + + run("test-uuid", "tag", opts={"tags": ["source:siprec-adapter", "pipeline:interop"]}) + + assert vcon.get_tag("source") == "siprec-adapter" + assert vcon.get_tag("pipeline") == "interop" + + +@patch("links.tag.VconRedis") +def test_run_applies_dict_form_tags(mock_vcon_redis): + # dict-form options: {name: value}. (CON-737) + vcon = Vcon.build_new() + mock_instance = mock_vcon_redis.return_value + mock_instance.get_vcon.return_value = vcon + + run("test-uuid", "tag", opts={"tags": {"env": "prod", "team": "interop"}}) + + assert vcon.get_tag("env") == "prod" + assert vcon.get_tag("team") == "interop" + + +@patch("links.tag.VconRedis") +def test_run_tolerates_dict_bodied_tags_attachment(mock_vcon_redis): + # A producer (e.g. the SIPREC adapter) wrote purpose="tags" with a dict + # body. add_tag must flatten it instead of raising on `.append`. (CON-737) + vcon = Vcon.build_new() + vcon.vcon_dict["attachments"].append( + {"purpose": "tags", "encoding": "json", "body": json.dumps({"source": "siprec"})} + ) + mock_instance = mock_vcon_redis.return_value + mock_instance.get_vcon.return_value = vcon + + result = run("test-uuid", "tag", opts={"tags": ["pipeline:interop"]}) + + assert result == "test-uuid" + assert vcon.get_tag("source") == "siprec" # pre-existing dict entry preserved + assert vcon.get_tag("pipeline") == "interop" # new tag appended + mock_instance.store_vcon.assert_called_once_with(vcon)