diff --git a/opengin/core-api/cmd/server/service.go b/opengin/core-api/cmd/server/service.go index 1f58c3d8..db279375 100644 --- a/opengin/core-api/cmd/server/service.go +++ b/opengin/core-api/cmd/server/service.go @@ -27,10 +27,10 @@ import ( // Server implements the COREService type Server struct { pb.UnimplementedCOREServiceServer - mongoRepo *mongorepository.MongoRepository - neo4jRepo *neo4jrepository.Neo4jRepository - postgresRepo *postgres.PostgresRepository - attributeProcessor *engine.EntityAttributeProcessor + mongoRepo *mongorepository.MongoRepository + neo4jRepo *neo4jrepository.Neo4jRepository + postgresRepo *postgres.PostgresRepository + attributeProcessor *engine.EntityAttributeProcessor } // CreateEntity handles entity creation with relationships, metadata and attributes @@ -354,29 +354,7 @@ func (s *Server) ReadEntities(ctx context.Context, req *pb.ReadEntityRequest) (* // Convert filtered entities to pb.Entity format var entities []*pb.Entity for _, entity := range filteredEntities { - pbEntity := &pb.Entity{ - Id: entity["id"].(string), - Kind: &pb.Kind{ - Major: entity["kind"].(string), - Minor: entity["minorKind"].(string), - }, - Created: entity["created"].(string), - Name: &pb.TimeBasedValue{ // How to represent time based value name? - StartTime: entity["created"].(string), - Value: &anypb.Any{ - TypeUrl: "type.googleapis.com/google.protobuf.StringValue", - Value: []byte(entity["name"].(string)), - }, - }, - } - - // Add terminated if present - if terminated, ok := entity["terminated"].(string); ok && terminated != "" { - pbEntity.Terminated = terminated - pbEntity.Name.EndTime = terminated - } - - entities = append(entities, pbEntity) + entities = append(entities, entityFromGraphFilterResult(entity)) } return &pb.EntityList{ diff --git a/opengin/core-api/cmd/server/utils.go b/opengin/core-api/cmd/server/utils.go index 4d12b405..85e2d9ea 100644 --- a/opengin/core-api/cmd/server/utils.go +++ b/opengin/core-api/cmd/server/utils.go @@ -5,10 +5,52 @@ package main import ( "fmt" - pb "lk/datafoundation/core-api/lk/datafoundation/core-api" "log" + + "lk/datafoundation/core-api/commons" + pb "lk/datafoundation/core-api/lk/datafoundation/core-api" ) +// entityFromGraphFilterResult maps a Neo4j filter row to a protobuf Entity. +// Name is packed as google.protobuf.StringValue (same framing as ReadEntity). +func entityFromGraphFilterResult(entity map[string]interface{}) *pb.Entity { + created := "" + if v, ok := entity["created"].(string); ok { + created = v + } + terminated := "" + if v, ok := entity["terminated"].(string); ok { + terminated = v + } + name := "" + if v, ok := entity["name"].(string); ok { + name = v + } + major := "" + if v, ok := entity["kind"].(string); ok { + major = v + } + minor := "" + if v, ok := entity["minorKind"].(string); ok { + minor = v + } + id := "" + if v, ok := entity["id"].(string); ok { + id = v + } + + return &pb.Entity{ + Id: id, + Kind: &pb.Kind{ + Major: major, + Minor: minor, + }, + Created: created, + Terminated: terminated, + Name: commons.CreateTimeBasedValue(created, terminated, name), + } +} + func debugMetadata(req *pb.Entity) { log.Printf("Reading Entity metadata: %s", req.Id) diff --git a/opengin/core-api/cmd/server/utils_test.go b/opengin/core-api/cmd/server/utils_test.go new file mode 100644 index 00000000..5f73d172 --- /dev/null +++ b/opengin/core-api/cmd/server/utils_test.go @@ -0,0 +1,72 @@ +// Copyright 2025 Lanka Data Foundation +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "testing" + + "lk/datafoundation/core-api/commons" + + "google.golang.org/protobuf/types/known/wrapperspb" +) + +func TestEntityFromGraphFilterResultPacksFramedStringValue(t *testing.T) { + const entityName = "Ministry of Health" + entity := entityFromGraphFilterResult(map[string]interface{}{ + "id": "minister-health-001", + "kind": "Organization", + "minorKind": "Minister", + "created": "2024-01-01T00:00:00Z", + "terminated": "", + "name": entityName, + }) + + if entity.Name == nil || entity.Name.Value == nil { + t.Fatal("expected name Any to be set") + } + + wantType := "type.googleapis.com/google.protobuf.StringValue" + if entity.Name.Value.TypeUrl != wantType { + t.Fatalf("typeUrl = %q, want %q", entity.Name.Value.TypeUrl, wantType) + } + + // Raw UTF-8 (the old ReadEntities bug) must not be stored in Any.Value. + if bytes.Equal(entity.Name.Value.Value, []byte(entityName)) { + t.Fatal("name Any.Value is raw UTF-8; expected protobuf StringValue framing") + } + + var unpacked wrapperspb.StringValue + if err := entity.Name.Value.UnmarshalTo(&unpacked); err != nil { + t.Fatalf("UnmarshalTo StringValue: %v", err) + } + if unpacked.Value != entityName { + t.Fatalf("unpacked name = %q, want %q", unpacked.Value, entityName) + } + + if got := commons.ExtractStringFromAny(entity.Name.Value); got != entityName { + t.Fatalf("ExtractStringFromAny = %q, want %q", got, entityName) + } + + if entity.Name.StartTime != "2024-01-01T00:00:00Z" { + t.Fatalf("StartTime = %q", entity.Name.StartTime) + } +} + +func TestEntityFromGraphFilterResultSetsTerminatedOnName(t *testing.T) { + entity := entityFromGraphFilterResult(map[string]interface{}{ + "id": "ended-entity", + "kind": "Person", + "minorKind": "Employee", + "created": "2024-01-01T00:00:00Z", + "terminated": "2024-12-31T00:00:00Z", + "name": "Ended", + }) + if entity.Terminated != "2024-12-31T00:00:00Z" { + t.Fatalf("Terminated = %q", entity.Terminated) + } + if entity.Name.EndTime != "2024-12-31T00:00:00Z" { + t.Fatalf("Name.EndTime = %q", entity.Name.EndTime) + } +} diff --git a/opengin/core-api/commons/utils_test.go b/opengin/core-api/commons/utils_test.go new file mode 100644 index 00000000..bcc33694 --- /dev/null +++ b/opengin/core-api/commons/utils_test.go @@ -0,0 +1,46 @@ +// Copyright 2025 Lanka Data Foundation +// SPDX-License-Identifier: Apache-2.0 + +package commons + +import ( + "bytes" + "testing" + + "google.golang.org/protobuf/types/known/anypb" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +func TestConvertStringToAnyIncludesProtobufFraming(t *testing.T) { + const name = "Ministry of Health" + packed := ConvertStringToAny(name) + + if bytes.Equal(packed.Value, []byte(name)) { + t.Fatal("ConvertStringToAny stored raw UTF-8; expected StringValue framing") + } + // google.protobuf.StringValue is field 1, length-delimited: 0x0A + if len(packed.Value) < 2 || packed.Value[0] != 0x0A || packed.Value[1] != byte(len(name)) { + t.Fatalf("missing StringValue framing, got %x", packed.Value) + } + + var sv wrapperspb.StringValue + if err := packed.UnmarshalTo(&sv); err != nil { + t.Fatalf("UnmarshalTo: %v", err) + } + if sv.Value != name { + t.Fatalf("unpacked = %q, want %q", sv.Value, name) + } + + // Contrast: labeling raw UTF-8 as StringValue (the old search bug) does not decode. + broken := &anypb.Any{ + TypeUrl: "type.googleapis.com/google.protobuf.StringValue", + Value: []byte(name), + } + var brokenSV wrapperspb.StringValue + if err := broken.UnmarshalTo(&brokenSV); err == nil && brokenSV.Value == name { + t.Fatal("raw UTF-8 Any unexpectedly unmarshaled to the full name") + } + if ExtractStringFromAny(broken) == name { + t.Fatal("ExtractStringFromAny should not treat unframed UTF-8 as a StringValue") + } +} diff --git a/opengin/read-api/read_api_service.bal b/opengin/read-api/read_api_service.bal index e1875374..2ae99973 100644 --- a/opengin/read-api/read_api_service.bal +++ b/opengin/read-api/read_api_service.bal @@ -37,21 +37,22 @@ COREServiceClient ep = check new (coreServiceUrl, grpcConfig); function extractValueAsString('any:Any anyValue) returns string { string typeUrl = anyValue.typeUrl; - // Default to using the value's string representation - string valueStr = anyValue.toString(); + // Unpack protobuf StringValue (used for entity names) to the plain string. + // any:toString() would otherwise return a JSON blob of {typeUrl, hex value}. + if typeUrl.endsWith("StringValue") || typeUrl.endsWith("string") { + string|error unpacked = pbAny:unpack(anyValue, string); + if unpacked is string { + return unpacked; + } + } - // Check if it's a known primitive type and handle accordingly - if (typeUrl.endsWith("string")) { - return anyValue.value.toString(); - } else if (typeUrl.endsWith("int32") || typeUrl.endsWith("int64") || + if (typeUrl.endsWith("int32") || typeUrl.endsWith("int64") || typeUrl.endsWith("float") || typeUrl.endsWith("double")) { return anyValue.value.toString(); } else if (typeUrl.endsWith("bool")) { return anyValue.value.toString(); - } else { - // For complex types, attempt to return as JSON string if possible - return valueStr; } + return anyValue.toString(); } // Helper function to convert decimal values to float for protobuf compatibility diff --git a/opengin/read-api/tests/read_api_service_test.bal b/opengin/read-api/tests/read_api_service_test.bal index d8067995..5c98288e 100644 --- a/opengin/read-api/tests/read_api_service_test.bal +++ b/opengin/read-api/tests/read_api_service_test.bal @@ -39,6 +39,14 @@ function unwrapAny(pbAny:Any anyValue) returns string|error { return pbAny:unpack(anyValue, string); } +@test:Config {} +function testExtractValueAsStringReturnsPlainName() returns error? { + pbAny:Any packed = check pbAny:pack("Ministry of Health"); + string name = extractValueAsString(packed); + test:assertEquals(name, "Ministry of Health"); + test:assertFalse(name.startsWith("{"), "name must not be a JSON-serialized Any"); +} + // Helper function to convert decimal values to float for protobuf compatibility // Note that this is a temporary solution to convert decimal values to float for protobuf compatibility. // It is not a permanent solution and should be removed when the protobuf library is updated to support decimal values. diff --git a/opengin/tests/e2e/basic_core_tests.py b/opengin/tests/e2e/basic_core_tests.py index 8ac3da21..ded9de51 100644 --- a/opengin/tests/e2e/basic_core_tests.py +++ b/opengin/tests/e2e/basic_core_tests.py @@ -708,17 +708,8 @@ def read_minister(self): f"Expected created date {self.START_DATE}, got {response_data['created']}" ) - print("im here befire you bitchhhhh....") - - # The name value is a protobuf Any that needs to be decoded - print("i am here..............") - name_obj = json.loads(response_data["name"]) - print(name_obj) - hex_value = name_obj["value"] - print(hex_value) - decoded_name = bytes.fromhex(hex_value).decode('utf-8') - assert decoded_name == "Minister of Finance and Economy", ( - f"Expected name 'Minister of Finance and Economy', got {decoded_name}" + assert response_data["name"] == "Minister of Finance and Economy", ( + f"Expected name 'Minister of Finance and Economy', got {response_data['name']}" ) def update_attributes_stage_1(self): diff --git a/opengin/tests/e2e/basic_read_tests.py b/opengin/tests/e2e/basic_read_tests.py index fe3e8465..56974547 100644 --- a/opengin/tests/e2e/basic_read_tests.py +++ b/opengin/tests/e2e/basic_read_tests.py @@ -18,6 +18,15 @@ def get_service_urls(): READ_API_URL = urls['read'] INGESTION_API_URL = urls['ingestion'] +def search_result_name(entity): + """Return the entity name from a search hit as a plain string.""" + name = entity.get("name") + if not isinstance(name, str): + raise AssertionError(f"search name should be a string, got {type(name)}: {name}") + if name.startswith("{") and "typeUrl" in name: + raise AssertionError(f"search name is still a serialized protobuf Any: {name}") + return name + ENTITY_ID = "read-test-entity" RELATED_ID_1 = "read-related-entity-1" RELATED_ID_2 = "read-related-entity-2" @@ -1428,6 +1437,9 @@ def test_search_by_name(): entity = body["body"][0] assert entity["id"] == MINISTER_ID_1, f"Expected minister ID {MINISTER_ID_1}, got {entity['id']}" assert entity["kind"]["minor"] == "Minister", f"Expected minor kind 'Minister', got {entity['kind']['minor']}" + assert search_result_name(entity) == "Ministry of Technology", ( + f"Expected name 'Ministry of Technology', got {entity['name']}" + ) print("✅ Search by name successful") @@ -1625,10 +1637,7 @@ def test_search_by_name_kind_and_created_date(): # Verify the names contain "Alice" for entity in body["body"]: if entity["id"] in [entity1_id, entity2_id]: - # Decode the name from protobuf - name_obj = json.loads(entity["name"]) - hex_value = name_obj["value"] - decoded_name = bytes.fromhex(hex_value).decode('utf-8') + decoded_name = search_result_name(entity) assert "Alice" in decoded_name, f"Entity {entity['id']} name '{decoded_name}' should contain 'Alice'" print(f"✅ Found {len(body['body'])} entities matching partial name 'Alice'") @@ -1762,10 +1771,7 @@ def test_search_by_partial_name_match(): assert len(entities) == 4, f"Expected 4 entities with 'Diana' in the name, got {len(entities)}" names = [] for entity in entities: - name_obj = json.loads(entity["name"]) - hex_value = name_obj["value"] - decoded_name = bytes.fromhex(hex_value).decode('utf-8') - names.append(decoded_name) + names.append(search_result_name(entity)) assert "Diana Thompson" in names, "Expected 'Diana Thompson' to be in results" assert "Diana Williams" in names, "Expected 'Diana Williams' to be in results" assert "Diana Thompson Williams" in names, "Expected 'Diana Thompson Williams' to be in results" @@ -1785,10 +1791,7 @@ def test_search_by_partial_name_match(): assert len(entities) == 3, f"Expected 3 entities with 'Thompson' in the name, got {len(entities)}" names = [] for entity in entities: - name_obj = json.loads(entity["name"]) - hex_value = name_obj["value"] - decoded_name = bytes.fromhex(hex_value).decode('utf-8') - names.append(decoded_name) + names.append(search_result_name(entity)) assert "Diana Thompson" in names, "Expected 'Diana Thompson' to be in results" assert "Franklin Thompson" in names, "Expected 'Franklin Thompson' to be in results" assert "Diana Thompson Williams" in names, "Expected 'Diana Thompson Williams' to be in results" @@ -1807,10 +1810,7 @@ def test_search_by_partial_name_match(): assert len(entities) == 4, f"Expected 4 entities with 'diana' (case-insensitive) in the name, got {len(entities)}" names = [] for entity in entities: - name_obj = json.loads(entity["name"]) - hex_value = name_obj["value"] - decoded_name = bytes.fromhex(hex_value).decode('utf-8') - names.append(decoded_name) + names.append(search_result_name(entity)) assert "Diana Thompson" in names, "Expected 'Diana Thompson' to be in results (case-insensitive match)" assert "Diana Williams" in names, "Expected 'Diana Williams' to be in results (case-insensitive match)" assert "Diana (test)" in names, "Expected 'Diana (test)' to be in results (case-insensitive match)" @@ -1830,10 +1830,7 @@ def test_search_by_partial_name_match(): assert len(entities) == 3, f"Expected 3 entities with 'WILLIAMS' (case-insensitive) in the name, got {len(entities)}" names = [] for entity in entities: - name_obj = json.loads(entity["name"]) - hex_value = name_obj["value"] - decoded_name = bytes.fromhex(hex_value).decode('utf-8') - names.append(decoded_name) + names.append(search_result_name(entity)) assert "Diana Williams" in names, "Expected 'Diana Williams' to be in results (case-insensitive match)" assert "Gabrielle Williams" in names, "Expected 'Gabrielle Williams' to be in results (case-insensitive match)" assert "Diana Thompson Williams" in names, "Expected 'Diana Thompson Williams' to be in results (case-insensitive match)" @@ -1852,10 +1849,7 @@ def test_search_by_partial_name_match(): assert len(entities) == 3, f"Expected 3 entities with 'Williams' in the name, got {len(entities)}" names = [] for entity in entities: - name_obj = json.loads(entity["name"]) - hex_value = name_obj["value"] - decoded_name = bytes.fromhex(hex_value).decode('utf-8') - names.append(decoded_name) + names.append(search_result_name(entity)) assert "Diana Williams" in names, "Expected 'Diana Williams' to be in results" assert "Gabrielle Williams" in names, "Expected 'Gabrielle Williams' to be in results" assert "Diana Thompson Williams" in names, "Expected 'Diana Thompson Williams' to be in results" @@ -1901,10 +1895,7 @@ def test_search_by_partial_name_match(): assert len(entities) == 4, f"Expected 4 entities with 'Diana' in the name and matching created date, got {len(entities)}" names = [] for entity in entities: - name_obj = json.loads(entity["name"]) - hex_value = name_obj["value"] - decoded_name = bytes.fromhex(hex_value).decode('utf-8') - names.append(decoded_name) + names.append(search_result_name(entity)) expected_names = ["Diana Thompson", "Diana Williams", "Diana Thompson Williams", "Diana (test)"] for name in names: assert name in expected_names, f"Expected entity name '{name}' to be one of the Diana entities" @@ -1936,10 +1927,7 @@ def test_search_by_partial_name_match(): assert len(entities) == 1, f"Expected 1 entity with 'Diana (test)' in the name, got {len(entities)}" names = [] for entity in entities: - name_obj = json.loads(entity["name"]) - hex_value = name_obj["value"] - decoded_name = bytes.fromhex(hex_value).decode('utf-8') - names.append(decoded_name) + names.append(search_result_name(entity)) assert "Diana (test)" in names, "Expected 'Diana (test)' to be in results" # Verify it doesn't match other Diana entities (regex injection would cause false matches) assert "Diana Thompson" not in names, "Should not match 'Diana Thompson' when searching for 'Diana (test)'" @@ -1958,10 +1946,7 @@ def test_search_by_partial_name_match(): assert len(entities) == 1, f"Expected 1 entity with 'Fran.klin' in the name, got {len(entities)}" names = [] for entity in entities: - name_obj = json.loads(entity["name"]) - hex_value = name_obj["value"] - decoded_name = bytes.fromhex(hex_value).decode('utf-8') - names.append(decoded_name) + names.append(search_result_name(entity)) assert "Fran.klin" in names, "Expected 'Fran.klin' to be in results" # Verify it doesn't match "Franklin" (regex injection would cause false matches) assert "Franklin Thompson" not in names, "Should not match 'Franklin Thompson' when searching for 'Fran.klin'" @@ -1980,10 +1965,7 @@ def test_search_by_partial_name_match(): assert len(entities) == 1, f"Expected 1 entity with '(test)' in the name, got {len(entities)}" names = [] for entity in entities: - name_obj = json.loads(entity["name"]) - hex_value = name_obj["value"] - decoded_name = bytes.fromhex(hex_value).decode('utf-8') - names.append(decoded_name) + names.append(search_result_name(entity)) assert "Diana (test)" in names, "Expected 'Diana (test)' to be in results" print(" ✅ Regex injection prevention - partial match with special chars successful") @@ -2125,11 +2107,7 @@ def test_search_by_id(): assert entity["id"] == DEPT_ID_1, f"Expected department ID {DEPT_ID_1}, got {entity['id']}" assert entity["kind"]["minor"] == "Department", f"Expected minor kind 'Department', got {entity['kind']['minor']}" - # Parse the name JSON string and decode the hex value directly - name_obj = json.loads(entity["name"]) - hex_value = name_obj["value"] - decoded_name = bytes.fromhex(hex_value).decode('utf-8') - + decoded_name = search_result_name(entity) assert decoded_name == "IT Department", f"Expected name 'IT Department', got {decoded_name}" print("✅ Search by ID successful") @@ -2182,10 +2160,7 @@ def test_search_by_id_with_other_filters(): assert entity["id"] == DEPT_ID_1, f"Expected department ID {DEPT_ID_1}, got {entity['id']}" assert entity["kind"]["minor"] == "Department", f"Expected minor kind 'Department', got {entity['kind']['minor']}" - # Decode the name value from protobuf Any type - name_obj = json.loads(entity["name"]) - hex_value = name_obj["value"] - decoded_name = bytes.fromhex(hex_value).decode('utf-8') + decoded_name = search_result_name(entity) assert decoded_name == "IT Department", f"Expected name 'IT Department', got {decoded_name}" print("✅ Search by ID ignored additional filters as expected") diff --git a/opengin/tests/e2e/test_orgchart_test.py b/opengin/tests/e2e/test_orgchart_test.py index 82a08bbd..4707411c 100644 --- a/opengin/tests/e2e/test_orgchart_test.py +++ b/opengin/tests/e2e/test_orgchart_test.py @@ -758,24 +758,14 @@ def test_orgchart_query(): assert kind["major"] == "Organization", f"Minister {i+1} has wrong major kind: {kind['major']}" assert kind["minor"] == "Minister", f"Minister {i+1} has wrong minor kind: {kind['minor']}" - # Decode and validate name structure + # Search returns a plain name string (not a protobuf Any blob) name = minister["name"] - if isinstance(name, str) and name.startswith('{"typeUrl"'): - # It's a protobuf encoded string - try: - name_data = json.loads(name) - decoded_name = decode_protobuf_any_value(name_data) - print(f" 📝 Decoded name: {decoded_name}") - assert isinstance(decoded_name, str), f"Minister {i+1} decoded name should be string" - assert len(decoded_name) > 0, f"Minister {i+1} decoded name should not be empty" - except Exception as e: - print(f" ⚠️ Failed to decode name: {e}") - # Continue with validation even if name decoding fails - else: - # Regular name structure - assert "value" in name, f"Minister {i+1} name missing 'value' field" - assert isinstance(name["value"], str), f"Minister {i+1} name value should be string" - assert len(name["value"]) > 0, f"Minister {i+1} name should not be empty" + assert isinstance(name, str), f"Minister {i+1} name should be a string, got {type(name)}: {name}" + assert not (name.startswith("{") and "typeUrl" in name), ( + f"Minister {i+1} name is still a serialized protobuf Any: {name}" + ) + assert len(name) > 0, f"Minister {i+1} name should not be empty" + print(f" 📝 Name: {name}") minister_ids.append(minister["id"]) print(f" ✅ Minister {i+1} validation passed: {minister['id']}") @@ -824,24 +814,14 @@ def test_orgchart_query(): assert kind["major"] == "Organization", f"Department {i+1} has wrong major kind: {kind['major']}" assert kind["minor"] == "Department", f"Department {i+1} has wrong minor kind: {kind['minor']}" - # Decode and validate name structure + # Search returns a plain name string (not a protobuf Any blob) name = dept["name"] - if isinstance(name, str) and name.startswith('{"typeUrl"'): - # It's a protobuf encoded string - try: - name_data = json.loads(name) - decoded_name = decode_protobuf_any_value(name_data) - print(f" 📝 Decoded name: {decoded_name}") - assert isinstance(decoded_name, str), f"Department {i+1} decoded name should be string" - assert len(decoded_name) > 0, f"Department {i+1} decoded name should not be empty" - except Exception as e: - print(f" ⚠️ Failed to decode name: {e}") - # Continue with validation even if name decoding fails - else: - # Regular name structure - assert "value" in name, f"Department {i+1} name missing 'value' field" - assert isinstance(name["value"], str), f"Department {i+1} name value should be string" - assert len(name["value"]) > 0, f"Department {i+1} name should not be empty" + assert isinstance(name, str), f"Department {i+1} name should be a string, got {type(name)}: {name}" + assert not (name.startswith("{") and "typeUrl" in name), ( + f"Department {i+1} name is still a serialized protobuf Any: {name}" + ) + assert len(name) > 0, f"Department {i+1} name should not be empty" + print(f" 📝 Name: {name}") dept_ids.append(dept["id"]) print(f" ✅ Department {i+1} validation passed: {dept['id']}") @@ -901,7 +881,8 @@ def test_get_attributes_of_one_minister(): # Just print what's in the object print(f" 📊 Attribute entity data: {json.dumps(attr_body, indent=2)}") name = attr_body[0]["name"] - print(f" ✅ Attribute Name: {decode_attribute_any_value(name)}") + assert isinstance(name, str), f"Attribute name should be a string, got {type(name)}: {name}" + print(f" ✅ Attribute Name: {name}") else: print(f" ❌ Failed to query attribute entity {attr_entity_id}: {search_res.status_code}")