diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 6ceef7bc2e..300b33a0ff 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -1182,6 +1182,11 @@ transport: type_cast_enabled: "${JSON_TYPE_CAST_ENABLED:true}" # Maximum allowed string value length when processing Telemetry/Attributes JSON (0 value disables string value length check) max_string_value_length: "${JSON_MAX_STRING_VALUE_LENGTH:0}" + # Preserve proto field names (e.g., 'current_fw_title') instead of converting to camelCase (e.g., 'currentFwTitle') when processing Protobuf messages. + # When set to 'false' (default), field names are converted to camelCase for backward compatibility. + # When set to 'true', field names are preserved as defined in the .proto schema. + # This affects MQTT, CoAP, and LwM2M transports using Protobuf payload. + preserve_proto_field_names: "${TB_TRANSPORT_JSON_PRESERVE_PROTO_FIELD_NAMES:false}" client_side_rpc: # Processing timeout interval of the RPC command on the CLIENT SIDE. Time in milliseconds timeout: "${CLIENT_SIDE_RPC_TIMEOUT:60000}" diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/DynamicProtoUtils.java b/common/data/src/main/java/org/thingsboard/server/common/data/DynamicProtoUtils.java index 54362a52fb..87167f2858 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/DynamicProtoUtils.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/DynamicProtoUtils.java @@ -45,6 +45,10 @@ public class DynamicProtoUtils { public static final Location LOCATION = new Location("", "", -1, -1); public static final String PROTO_3_SYNTAX = "proto3"; + + private static final JsonFormat.Printer JSON_PRINTER = JsonFormat.printer().includingDefaultValueFields(); + private static final JsonFormat.Printer JSON_PRINTER_PRESERVING_PROTO_FIELD_NAMES = JsonFormat.printer().preservingProtoFieldNames().includingDefaultValueFields(); + private static boolean preserveProtoFieldNames = Boolean.parseBoolean(System.getProperty("transport.json.preserve_proto_field_names", System.getenv("TB_TRANSPORT_JSON_PRESERVE_PROTO_FIELD_NAMES"))); public static Descriptors.Descriptor getDescriptor(String protoSchema, String schemaName) { try { @@ -99,7 +103,8 @@ public class DynamicProtoUtils { public static String dynamicMsgToJson(Descriptors.Descriptor descriptor, byte[] payload) throws InvalidProtocolBufferException { DynamicMessage dynamicMessage = DynamicMessage.parseFrom(descriptor, payload); - return JsonFormat.printer().includingDefaultValueFields().print(dynamicMessage); + JsonFormat.Printer printer = preserveProtoFieldNames ? JSON_PRINTER_PRESERVING_PROTO_FIELD_NAMES : JSON_PRINTER; + return printer.print(dynamicMessage); } public static DynamicMessage jsonToDynamicMessage(DynamicMessage.Builder builder, String payload) throws InvalidProtocolBufferException { @@ -107,6 +112,10 @@ public class DynamicProtoUtils { return builder.build(); } + static void setPreserveProtoFieldNames(boolean preserve) { + preserveProtoFieldNames = preserve; + } + private static List getMessageTypes(List types) { return types.stream() .filter(typeElement -> typeElement instanceof MessageElement) diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/DynamicProtoUtilsTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/DynamicProtoUtilsTest.java index 4f11013c05..2988ea0790 100644 --- a/common/data/src/test/java/org/thingsboard/server/common/data/DynamicProtoUtilsTest.java +++ b/common/data/src/test/java/org/thingsboard/server/common/data/DynamicProtoUtilsTest.java @@ -19,20 +19,34 @@ import com.github.os72.protobuf.dynamic.DynamicSchema; import com.google.protobuf.Descriptors; import com.google.protobuf.DynamicMessage; import com.squareup.wire.schema.internal.parser.ProtoFileElement; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.junit.jupiter.MockitoExtension; +import org.junit.jupiter.api.parallel.Isolated; import java.util.List; import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; -@ExtendWith(MockitoExtension.class) +@Isolated("DynamicProtoUtils static settings being modified") public class DynamicProtoUtilsTest { + @BeforeEach + public void before() { + // Restore default state before each test + DynamicProtoUtils.setPreserveProtoFieldNames(false); + } + + @AfterEach + public void after() { + // Restore default state after each test + DynamicProtoUtils.setPreserveProtoFieldNames(false); + } + @Test public void testProtoSchemaWithMessageNestedTypes() throws Exception { String schema = "syntax = \"proto3\";\n" + @@ -166,4 +180,96 @@ public class DynamicProtoUtilsTest { DynamicProtoUtils.dynamicMsgToJson(sampleMsgDescriptor, sampleMsgWithOneOfSubMessage.toByteArray())); } + @Test + public void testProtoSchemaDefaultBehaviorConvertsToCamelCase() throws Exception { + // Explicitly set to false to test default behavior (camelCase conversion) + DynamicProtoUtils.setPreserveProtoFieldNames(false); + + String schema = "syntax = \"proto3\";\n" + + "\n" + + "package firmware;\n" + + "\n" + + "message FirmwareStatus {\n" + + " string current_fw_title = 1;\n" + + " string current_fw_version = 2;\n" + + " string fw_state = 3;\n" + + " string target_fw_title = 4;\n" + + " string target_fw_version = 5;\n" + + "}"; + ProtoFileElement protoFileElement = DynamicProtoUtils.getProtoFileElement(schema); + DynamicSchema dynamicSchema = DynamicProtoUtils.getDynamicSchema(protoFileElement, "test schema with snake_case fields"); + assertNotNull(dynamicSchema); + + DynamicMessage.Builder firmwareStatusBuilder = dynamicSchema.newMessageBuilder("firmware.FirmwareStatus"); + Descriptors.Descriptor firmwareStatusDescriptor = firmwareStatusBuilder.getDescriptorForType(); + assertNotNull(firmwareStatusDescriptor); + + DynamicMessage firmwareStatus = firmwareStatusBuilder + .setField(firmwareStatusDescriptor.findFieldByName("current_fw_title"), "firmware_v1") + .setField(firmwareStatusDescriptor.findFieldByName("current_fw_version"), "1.0.0") + .setField(firmwareStatusDescriptor.findFieldByName("fw_state"), "DOWNLOADING") + .setField(firmwareStatusDescriptor.findFieldByName("target_fw_title"), "firmware_v2") + .setField(firmwareStatusDescriptor.findFieldByName("target_fw_version"), "2.0.0") + .build(); + + String json = DynamicProtoUtils.dynamicMsgToJson(firmwareStatusDescriptor, firmwareStatus.toByteArray()); + + // Default behavior: field names converted to camelCase + assertTrue(json.contains("\"currentFwTitle\""), "JSON should contain camelCase field 'currentFwTitle'"); + assertTrue(json.contains("\"currentFwVersion\""), "JSON should contain camelCase field 'currentFwVersion'"); + assertTrue(json.contains("\"fwState\""), "JSON should contain camelCase field 'fwState'"); + assertTrue(json.contains("\"targetFwTitle\""), "JSON should contain camelCase field 'targetFwTitle'"); + assertTrue(json.contains("\"targetFwVersion\""), "JSON should contain camelCase field 'targetFwVersion'"); + + // Verify snake_case versions are NOT present + assertFalse(json.contains("\"current_fw_title\""), "JSON should NOT contain snake_case field 'current_fw_title'"); + assertFalse(json.contains("\"fw_state\""), "JSON should NOT contain snake_case field 'fw_state'"); + } + + @Test + public void testProtoSchemaPreservesSnakeCaseFieldNamesWhenEnabled() throws Exception { + // Explicitly set to true to test preserve behavior (snake_case preservation) + DynamicProtoUtils.setPreserveProtoFieldNames(true); + + String schema = "syntax = \"proto3\";\n" + + "\n" + + "package firmware;\n" + + "\n" + + "message FirmwareStatus {\n" + + " string current_fw_title = 1;\n" + + " string current_fw_version = 2;\n" + + " string fw_state = 3;\n" + + " string target_fw_title = 4;\n" + + " string target_fw_version = 5;\n" + + "}"; + ProtoFileElement protoFileElement = DynamicProtoUtils.getProtoFileElement(schema); + DynamicSchema dynamicSchema = DynamicProtoUtils.getDynamicSchema(protoFileElement, "test schema with snake_case fields"); + assertNotNull(dynamicSchema); + + DynamicMessage.Builder firmwareStatusBuilder = dynamicSchema.newMessageBuilder("firmware.FirmwareStatus"); + Descriptors.Descriptor firmwareStatusDescriptor = firmwareStatusBuilder.getDescriptorForType(); + assertNotNull(firmwareStatusDescriptor); + + DynamicMessage firmwareStatus = firmwareStatusBuilder + .setField(firmwareStatusDescriptor.findFieldByName("current_fw_title"), "firmware_v1") + .setField(firmwareStatusDescriptor.findFieldByName("current_fw_version"), "1.0.0") + .setField(firmwareStatusDescriptor.findFieldByName("fw_state"), "DOWNLOADING") + .setField(firmwareStatusDescriptor.findFieldByName("target_fw_title"), "firmware_v2") + .setField(firmwareStatusDescriptor.findFieldByName("target_fw_version"), "2.0.0") + .build(); + + String json = DynamicProtoUtils.dynamicMsgToJson(firmwareStatusDescriptor, firmwareStatus.toByteArray()); + + // When flag is enabled, verify snake_case is preserved + assertTrue(json.contains("\"current_fw_title\""), "JSON should contain snake_case field 'current_fw_title'"); + assertTrue(json.contains("\"current_fw_version\""), "JSON should contain snake_case field 'current_fw_version'"); + assertTrue(json.contains("\"fw_state\""), "JSON should contain snake_case field 'fw_state'"); + assertTrue(json.contains("\"target_fw_title\""), "JSON should contain snake_case field 'target_fw_title'"); + assertTrue(json.contains("\"target_fw_version\""), "JSON should contain snake_case field 'target_fw_version'"); + + // Verify camelCase versions are NOT present + assertFalse(json.contains("\"currentFwTitle\""), "JSON should NOT contain camelCase field 'currentFwTitle'"); + assertFalse(json.contains("\"fwState\""), "JSON should NOT contain camelCase field 'fwState'"); + } + } diff --git a/transport/coap/src/main/resources/tb-coap-transport.yml b/transport/coap/src/main/resources/tb-coap-transport.yml index 3bf2295ec9..02f32a113e 100644 --- a/transport/coap/src/main/resources/tb-coap-transport.yml +++ b/transport/coap/src/main/resources/tb-coap-transport.yml @@ -165,6 +165,11 @@ transport: type_cast_enabled: "${JSON_TYPE_CAST_ENABLED:true}" # Maximum allowed string value length when processing Telemetry/Attributes JSON (0 value disables string value length check) max_string_value_length: "${JSON_MAX_STRING_VALUE_LENGTH:0}" + # Preserve proto field names (e.g., 'current_fw_title') instead of converting to camelCase (e.g., 'currentFwTitle') when processing Protobuf messages. + # When set to 'false' (default), field names are converted to camelCase for backward compatibility. + # When set to 'true', field names are preserved as defined in the .proto schema. + # This affects dynamic Protobuf messages used in device communication. + preserve_proto_field_names: "${TB_TRANSPORT_JSON_PRESERVE_PROTO_FIELD_NAMES:false}" log: # Enable/Disable log of transport messages to telemetry. For example, logging of LwM2M registration update enabled: "${TB_TRANSPORT_LOG_ENABLED:true}" diff --git a/transport/http/src/main/resources/tb-http-transport.yml b/transport/http/src/main/resources/tb-http-transport.yml index 945a63240a..0fa0e7e9a4 100644 --- a/transport/http/src/main/resources/tb-http-transport.yml +++ b/transport/http/src/main/resources/tb-http-transport.yml @@ -197,6 +197,11 @@ transport: type_cast_enabled: "${JSON_TYPE_CAST_ENABLED:true}" # Maximum allowed string value length when processing Telemetry/Attributes JSON (0 value disables string value length check) max_string_value_length: "${JSON_MAX_STRING_VALUE_LENGTH:0}" + # Preserve proto field names (e.g., 'current_fw_title') instead of converting to camelCase (e.g., 'currentFwTitle') when processing Protobuf messages. + # When set to 'false' (default), field names are converted to camelCase for backward compatibility. + # When set to 'true', field names are preserved as defined in the .proto schema. + # This affects dynamic Protobuf messages used in device communication. + preserve_proto_field_names: "${TB_TRANSPORT_JSON_PRESERVE_PROTO_FIELD_NAMES:false}" log: # Enable/Disable log of transport messages to telemetry. For example, logging of LwM2M registration update enabled: "${TB_TRANSPORT_LOG_ENABLED:true}" diff --git a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml index 85068152a6..8ad646e1b1 100644 --- a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml +++ b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml @@ -157,6 +157,11 @@ transport: type_cast_enabled: "${JSON_TYPE_CAST_ENABLED:false}" # Maximum allowed string value length when processing Telemetry/Attributes JSON (0 value disables string value length check) max_string_value_length: "${JSON_MAX_STRING_VALUE_LENGTH:0}" + # Preserve proto field names (e.g., 'current_fw_title') instead of converting to camelCase (e.g., 'currentFwTitle') when processing Protobuf messages. + # When set to 'false' (default), field names are converted to camelCase for backward compatibility. + # When set to 'true', field names are preserved as defined in the .proto schema. + # This affects dynamic Protobuf messages used in device communication. + preserve_proto_field_names: "${TB_TRANSPORT_JSON_PRESERVE_PROTO_FIELD_NAMES:false}" client_side_rpc: # Processing timeout interval of the RPC command on the CLIENT SIDE. Time in milliseconds timeout: "${CLIENT_SIDE_RPC_TIMEOUT:60000}" diff --git a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml index 52d469e249..566bfef49f 100644 --- a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml +++ b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml @@ -220,6 +220,11 @@ transport: type_cast_enabled: "${JSON_TYPE_CAST_ENABLED:true}" # Maximum allowed string value length when processing Telemetry/Attributes JSON (0 value disables string value length check) max_string_value_length: "${JSON_MAX_STRING_VALUE_LENGTH:0}" + # Preserve proto field names (e.g., 'current_fw_title') instead of converting to camelCase (e.g., 'currentFwTitle') when processing Protobuf messages. + # When set to 'false' (default), field names are converted to camelCase for backward compatibility. + # When set to 'true', field names are preserved as defined in the .proto schema. + # This affects dynamic Protobuf messages used in device communication. + preserve_proto_field_names: "${TB_TRANSPORT_JSON_PRESERVE_PROTO_FIELD_NAMES:false}" log: # Enable/Disable log of transport messages to telemetry. For example, logging of LwM2M registration update enabled: "${TB_TRANSPORT_LOG_ENABLED:true}" diff --git a/transport/snmp/src/main/resources/tb-snmp-transport.yml b/transport/snmp/src/main/resources/tb-snmp-transport.yml index 46b1d8e48b..9e23961e09 100644 --- a/transport/snmp/src/main/resources/tb-snmp-transport.yml +++ b/transport/snmp/src/main/resources/tb-snmp-transport.yml @@ -178,6 +178,11 @@ transport: type_cast_enabled: "${JSON_TYPE_CAST_ENABLED:true}" # Maximum allowed string value length when processing Telemetry/Attributes JSON (0 value disables string value length check) max_string_value_length: "${JSON_MAX_STRING_VALUE_LENGTH:0}" + # Preserve proto field names (e.g., 'current_fw_title') instead of converting to camelCase (e.g., 'currentFwTitle') when processing Protobuf messages. + # When set to 'false' (default), field names are converted to camelCase for backward compatibility. + # When set to 'true', field names are preserved as defined in the .proto schema. + # This affects dynamic Protobuf messages used in device communication. + preserve_proto_field_names: "${TB_TRANSPORT_JSON_PRESERVE_PROTO_FIELD_NAMES:false}" log: # Enable/Disable log of transport messages to telemetry. For example, logging of LwM2M registration update enabled: "${TB_TRANSPORT_LOG_ENABLED:true}"