This is an automated email from the ASF dual-hosted git repository.
exceptionfactory pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git
The following commit(s) were added to refs/heads/main by this push:
new 4abef54d295 NIFI-16349 Strip custom Protobuf options before Wire
schema compilation (#11710)
4abef54d295 is described below
commit 4abef54d295f2c3f55c8d09fb1e61f78625259eb
Author: Alaksiej Ščarbaty <[email protected]>
AuthorDate: Wed Sep 23 19:41:06 2026 +0200
NIFI-16349 Strip custom Protobuf options before Wire schema compilation
(#11710)
Signed-off-by: David Handermann <[email protected]>
---
.../protobuf/ProtobufCustomOptionRemover.java | 268 ++++++++++++++
.../services/protobuf/ProtobufSchemaCompiler.java | 7 +-
.../protobuf/TestProtobufSchemaCompiler.java | 403 +++++++++++++++++++++
.../protobuf/TestStandardProtobufReader.java | 78 ++++
.../test/custom_options_on_all_elements.proto | 66 ++++
.../test/nested_wrapper_with_field_meta.proto | 72 ++++
6 files changed, 891 insertions(+), 3 deletions(-)
diff --git
a/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/main/java/org/apache/nifi/services/protobuf/ProtobufCustomOptionRemover.java
b/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/main/java/org/apache/nifi/services/protobuf/ProtobufCustomOptionRemover.java
new file mode 100644
index 00000000000..e147d604662
--- /dev/null
+++
b/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/main/java/org/apache/nifi/services/protobuf/ProtobufCustomOptionRemover.java
@@ -0,0 +1,268 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.services.protobuf;
+
+import com.squareup.wire.schema.Location;
+import com.squareup.wire.schema.internal.parser.EnumConstantElement;
+import com.squareup.wire.schema.internal.parser.EnumElement;
+import com.squareup.wire.schema.internal.parser.ExtendElement;
+import com.squareup.wire.schema.internal.parser.ExtensionsElement;
+import com.squareup.wire.schema.internal.parser.FieldElement;
+import com.squareup.wire.schema.internal.parser.GroupElement;
+import com.squareup.wire.schema.internal.parser.MessageElement;
+import com.squareup.wire.schema.internal.parser.OneOfElement;
+import com.squareup.wire.schema.internal.parser.OptionElement;
+import com.squareup.wire.schema.internal.parser.ProtoFileElement;
+import com.squareup.wire.schema.internal.parser.ProtoParser;
+import com.squareup.wire.schema.internal.parser.RpcElement;
+import com.squareup.wire.schema.internal.parser.ServiceElement;
+import com.squareup.wire.schema.internal.parser.TypeElement;
+import org.apache.nifi.logging.ComponentLog;
+
+import java.util.ArrayList;
+import java.util.List;
+
+final class ProtobufCustomOptionRemover {
+
+ private ProtobufCustomOptionRemover() {
+ }
+
+ /**
+ * Removes custom (parenthesized) Protocol Buffer options from schema text.
+ * Built-in options such as {@code packed} and {@code java_package} are
retained.
+ * When the text cannot be parsed, the original string is returned.
+ *
+ * @param schemaText source proto schema to remove custom options from
+ * @param location source location used for parse diagnostics and
regenerated comments
+ * @param logger component logger
+ * @return schema text without custom options, or the original text when
parsing fails or no custom options are present
+ */
+ static String remove(final String schemaText, final Location location,
final ComponentLog logger) {
+ if (schemaText == null || schemaText.isBlank()) {
+ return schemaText;
+ }
+
+ final ProtoFileElement parsed;
+ try {
+ parsed = ProtoParser.Companion.parse(location, schemaText);
+ } catch (final IllegalStateException e) {
+ logger.warn("Failed to parse Protobuf schema [{}] while removing
custom options; using the original schema text", location, e);
+ return schemaText;
+ }
+
+ final ProtoFileElement withoutCustomOptions = removeFromFile(parsed);
+ if (parsed.equals(withoutCustomOptions)) {
+ return schemaText;
+ }
+
+ return withoutCustomOptions.toSchema();
+ }
+
+ private static ProtoFileElement removeFromFile(final ProtoFileElement
file) {
+ return new ProtoFileElement(
+ file.getLocation(),
+ file.getPackageName(),
+ file.getSyntax(),
+ file.getImports(),
+ file.getPublicImports(),
+ file.getWeakImports(),
+ removeFromTypes(file.getTypes()),
+ removeFromServices(file.getServices()),
+ removeFromExtendDeclarations(file.getExtendDeclarations()),
+ removeCustomOptions(file.getOptions())
+ );
+ }
+
+ private static List<TypeElement> removeFromTypes(final List<TypeElement>
types) {
+ final List<TypeElement> updatedTypes = new ArrayList<>(types.size());
+ for (final TypeElement type : types) {
+ updatedTypes.add(removeFromType(type));
+ }
+
+ return updatedTypes;
+ }
+
+ private static TypeElement removeFromType(final TypeElement type) {
+ if (type instanceof MessageElement message) {
+ return new MessageElement(
+ message.getLocation(),
+ message.getName(),
+ message.getDocumentation(),
+ removeFromTypes(message.getNestedTypes()),
+ removeCustomOptions(message.getOptions()),
+ message.getReserveds(),
+ removeFromFields(message.getFields()),
+ removeFromOneOfs(message.getOneOfs()),
+ removeFromExtensions(message.getExtensions()),
+ removeFromGroups(message.getGroups()),
+ removeFromExtendDeclarations(message.getExtendDeclarations())
+ );
+ }
+
+ if (type instanceof EnumElement enumeration) {
+ return new EnumElement(
+ enumeration.getLocation(),
+ enumeration.getName(),
+ enumeration.getDocumentation(),
+ removeCustomOptions(enumeration.getOptions()),
+ removeFromEnumConstants(enumeration.getConstants()),
+ enumeration.getReserveds()
+ );
+ }
+
+ return type;
+ }
+
+ private static List<FieldElement> removeFromFields(final
List<FieldElement> fields) {
+ final List<FieldElement> updatedFields = new
ArrayList<>(fields.size());
+ for (final FieldElement field : fields) {
+ updatedFields.add(new FieldElement(
+ field.getLocation(),
+ field.getLabel(),
+ field.getType(),
+ field.getName(),
+ field.getDefaultValue(),
+ field.getJsonName(),
+ field.getTag(),
+ field.getDocumentation(),
+ removeCustomOptions(field.getOptions())
+ ));
+ }
+
+ return updatedFields;
+ }
+
+ private static List<OneOfElement> removeFromOneOfs(final
List<OneOfElement> oneOfs) {
+ final List<OneOfElement> updatedOneOfs = new
ArrayList<>(oneOfs.size());
+ for (final OneOfElement oneOf : oneOfs) {
+ updatedOneOfs.add(new OneOfElement(
+ oneOf.getName(),
+ oneOf.getDocumentation(),
+ removeFromFields(oneOf.getFields()),
+ removeFromGroups(oneOf.getGroups()),
+ removeCustomOptions(oneOf.getOptions()),
+ oneOf.getLocation()
+ ));
+ }
+
+ return updatedOneOfs;
+ }
+
+ private static List<GroupElement> removeFromGroups(final
List<GroupElement> groups) {
+ final List<GroupElement> updatedGroups = new
ArrayList<>(groups.size());
+ for (final GroupElement group : groups) {
+ updatedGroups.add(new GroupElement(
+ group.getLabel(),
+ group.getLocation(),
+ group.getName(),
+ group.getTag(),
+ group.getDocumentation(),
+ removeFromFields(group.getFields())
+ ));
+ }
+
+ return updatedGroups;
+ }
+
+ private static List<ExtensionsElement> removeFromExtensions(final
List<ExtensionsElement> extensions) {
+ final List<ExtensionsElement> updatedExtensions = new
ArrayList<>(extensions.size());
+ for (final ExtensionsElement extension : extensions) {
+ updatedExtensions.add(new ExtensionsElement(
+ extension.getLocation(),
+ extension.getDocumentation(),
+ extension.getValues(),
+ removeCustomOptions(extension.getOptions())
+ ));
+ }
+
+ return updatedExtensions;
+ }
+
+ private static List<ExtendElement> removeFromExtendDeclarations(final
List<ExtendElement> extendDeclarations) {
+ final List<ExtendElement> updatedDeclarations = new
ArrayList<>(extendDeclarations.size());
+ for (final ExtendElement extendDeclaration : extendDeclarations) {
+ updatedDeclarations.add(new ExtendElement(
+ extendDeclaration.getLocation(),
+ extendDeclaration.getName(),
+ extendDeclaration.getDocumentation(),
+ removeFromFields(extendDeclaration.getFields())
+ ));
+ }
+
+ return updatedDeclarations;
+ }
+
+ private static List<EnumConstantElement> removeFromEnumConstants(final
List<EnumConstantElement> constants) {
+ final List<EnumConstantElement> updatedConstants = new
ArrayList<>(constants.size());
+ for (final EnumConstantElement constant : constants) {
+ updatedConstants.add(new EnumConstantElement(
+ constant.getLocation(),
+ constant.getName(),
+ constant.getTag(),
+ constant.getDocumentation(),
+ removeCustomOptions(constant.getOptions())
+ ));
+ }
+
+ return updatedConstants;
+ }
+
+ private static List<ServiceElement> removeFromServices(final
List<ServiceElement> services) {
+ final List<ServiceElement> updatedServices = new
ArrayList<>(services.size());
+ for (final ServiceElement service : services) {
+ updatedServices.add(new ServiceElement(
+ service.getLocation(),
+ service.getName(),
+ service.getDocumentation(),
+ removeFromRpcs(service.getRpcs()),
+ removeCustomOptions(service.getOptions())
+ ));
+ }
+
+ return updatedServices;
+ }
+
+ private static List<RpcElement> removeFromRpcs(final List<RpcElement>
rpcs) {
+ final List<RpcElement> updatedRpcs = new ArrayList<>(rpcs.size());
+ for (final RpcElement rpc : rpcs) {
+ updatedRpcs.add(new RpcElement(
+ rpc.getLocation(),
+ rpc.getName(),
+ rpc.getDocumentation(),
+ rpc.getRequestType(),
+ rpc.getResponseType(),
+ rpc.getRequestStreaming(),
+ rpc.getResponseStreaming(),
+ removeCustomOptions(rpc.getOptions())
+ ));
+ }
+
+ return updatedRpcs;
+ }
+
+ private static List<OptionElement> removeCustomOptions(final
List<OptionElement> options) {
+ final List<OptionElement> retainedOptions = new
ArrayList<>(options.size());
+ for (final OptionElement option : options) {
+ // Custom options are always parenthesized. Built-in options are
not.
+ if (!option.isParenthesized()) {
+ retainedOptions.add(option);
+ }
+ }
+
+ return retainedOptions;
+ }
+}
diff --git
a/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/main/java/org/apache/nifi/services/protobuf/ProtobufSchemaCompiler.java
b/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/main/java/org/apache/nifi/services/protobuf/ProtobufSchemaCompiler.java
index da4f20eac55..6e264a19292 100644
---
a/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/main/java/org/apache/nifi/services/protobuf/ProtobufSchemaCompiler.java
+++
b/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/main/java/org/apache/nifi/services/protobuf/ProtobufSchemaCompiler.java
@@ -29,6 +29,7 @@ import org.apache.nifi.serialization.record.SchemaIdentifier;
import java.io.IOException;
import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -195,10 +196,10 @@ final class ProtobufSchemaCompiler {
final String schemaFileName = generateSchemaFileName(schemaDefinition);
final Path schemaFile = tempDir.resolve(schemaFileName);
- // Write schema text to file
- Files.write(schemaFile, schemaDefinition.getText().getBytes(), CREATE,
WRITE, TRUNCATE_EXISTING);
+ final String preparedSchemaDefinition =
ProtobufCustomOptionRemover.remove(schemaDefinition.getText(),
Location.get(schemaFileName), logger);
+ Files.write(schemaFile,
preparedSchemaDefinition.getBytes(StandardCharsets.UTF_8), CREATE, WRITE,
TRUNCATE_EXISTING);
logger.debug("Successfully wrote schema to file: {} (string length:
{})",
- schemaFile, schemaDefinition.getText().length());
+ schemaFile, preparedSchemaDefinition.length());
}
/**
diff --git
a/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/test/java/org/apache/nifi/services/protobuf/TestProtobufSchemaCompiler.java
b/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/test/java/org/apache/nifi/services/protobuf/TestProtobufSchemaCompiler.java
new file mode 100644
index 00000000000..64128d24439
--- /dev/null
+++
b/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/test/java/org/apache/nifi/services/protobuf/TestProtobufSchemaCompiler.java
@@ -0,0 +1,403 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.services.protobuf;
+
+import com.squareup.wire.schema.EnumConstant;
+import com.squareup.wire.schema.EnumType;
+import com.squareup.wire.schema.Field;
+import com.squareup.wire.schema.MessageType;
+import com.squareup.wire.schema.OneOf;
+import com.squareup.wire.schema.Options;
+import com.squareup.wire.schema.ProtoFile;
+import com.squareup.wire.schema.Rpc;
+import com.squareup.wire.schema.Schema;
+import com.squareup.wire.schema.SchemaException;
+import com.squareup.wire.schema.Service;
+import com.squareup.wire.schema.internal.parser.OptionElement;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.schemaregistry.services.SchemaDefinition;
+import org.apache.nifi.schemaregistry.services.StandardSchemaDefinition;
+import org.apache.nifi.serialization.record.SchemaIdentifier;
+import org.apache.nifi.util.MockComponentLog;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.function.Executable;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class TestProtobufSchemaCompiler {
+
+ private static final ComponentLog COMPONENT_LOG = new
MockComponentLog("test", TestProtobufSchemaCompiler.class);
+
+ private static final String PACKAGE = "example";
+ private static final String SAMPLE_TYPE = PACKAGE + ".Sample";
+ private static final String WRAPPER_TYPE = PACKAGE + ".Wrapper";
+ private static final String WRAPPER_PAYLOAD_TYPE = WRAPPER_TYPE +
".Payload";
+
+ private static final String SAMPLE_PROTO = "sample.proto";
+ private static final String COMMON_PROTO = "common.proto";
+
+ private static final String FIELD_VALUE = "value";
+ private static final String FIELD_EXTRA = "extra";
+ private static final String ENUM_RED = "RED";
+
+ private final ProtobufSchemaCompiler compiler = new
ProtobufSchemaCompiler("test", COMPONENT_LOG);
+
+ @Test
+ void testCompileSchemaWithCustomOptionWithoutImport() {
+ final String schemaText = """
+ syntax = "proto3";
+ package example;
+
+ message Sample {
+ optional int32 enabled = 1 [(confluent.field_meta) = {
+ params: [
+ {
+ key: "connect.type",
+ value: "int16"
+ }
+ ]
+ }];
+ }
+ """;
+ final SchemaDefinition schemaDefinition =
schemaDefinition(SAMPLE_PROTO, schemaText);
+
+ final Schema schema = compiler.compileOrGetFromCache(schemaDefinition);
+
+ final Field enabled = schema.getField(SAMPLE_TYPE, "enabled");
+
+ assertNotNull(enabled);
+ assertNoParenthesizedOptions(enabled.getOptions());
+ }
+
+ @Test
+ void testCompileNestedSchemaWithRepeatedFieldMetaOptions() throws
IOException {
+ final String schemaText =
readTestResource("/org/apache/nifi/protobuf/test/nested_wrapper_with_field_meta.proto");
+ final SchemaDefinition schemaDefinition =
schemaDefinition(SAMPLE_PROTO, schemaText);
+
+ final Schema schema = compiler.compileOrGetFromCache(schemaDefinition);
+
+ final Field flagA = schema.getField(WRAPPER_PAYLOAD_TYPE, "flag_a");
+ final Field flagB = schema.getField(WRAPPER_PAYLOAD_TYPE, "flag_b");
+ final Field flagC = schema.getField(WRAPPER_PAYLOAD_TYPE, "flag_c");
+
+ assertNotNull(schema.getType(WRAPPER_TYPE));
+ assertNotNull(schema.getType(WRAPPER_TYPE + ".Origin"));
+ assertNotNull(schema.getType(WRAPPER_TYPE + ".chunk"));
+ assertNotNull(flagA);
+ assertNotNull(flagB);
+ assertNotNull(flagC);
+ assertNoParenthesizedOptions(flagA.getOptions());
+ assertNoParenthesizedOptions(flagB.getOptions());
+ assertNoParenthesizedOptions(flagC.getOptions());
+ }
+
+ @Test
+ void testCompileKeepsPackedWhenCustomFieldOptionPresent() {
+ final String schemaText = """
+ syntax = "proto2";
+ package example;
+
+ message Sample {
+ repeated int32 values = 1 [packed = true, (confluent.field_meta)
= {
+ tags: "x"
+ }];
+ }
+ """;
+ final SchemaDefinition schemaDefinition =
schemaDefinition(SAMPLE_PROTO, schemaText);
+
+ final Schema schema = compiler.compileOrGetFromCache(schemaDefinition);
+ final Field values = schema.getField(SAMPLE_TYPE, "values");
+
+ assertNotNull(values);
+ assertTrue(values.isRepeated());
+ assertTrue(values.isPacked());
+ assertNoParenthesizedOptions(values.getOptions());
+ }
+
+ @Test
+ void testCompileKeepsJavaPackageWhenCustomFileOptionPresent() {
+ final String schemaText = """
+ syntax = "proto3";
+ package example;
+
+ option java_package = "com.example";
+ option (custom.file_meta) = "docs";
+
+ message Sample {
+ int32 value = 1;
+ }
+ """;
+ final SchemaDefinition schemaDefinition =
schemaDefinition(SAMPLE_PROTO, schemaText);
+
+ final Schema schema = compiler.compileOrGetFromCache(schemaDefinition);
+ final ProtoFile protoFile = schema.protoFile(SAMPLE_PROTO);
+
+ assertNotNull(schema.getType(SAMPLE_TYPE));
+ assertNotNull(protoFile);
+ assertEquals("com.example", protoFile.javaPackage());
+ assertNoParenthesizedOptions(protoFile.getOptions());
+ }
+
+ @Test
+ void testCompileSchemaWithoutCustomOptions() {
+ final String schemaText = """
+ syntax = "proto3";
+ package example;
+
+ message Sample {
+ int32 value = 1;
+ }
+ """;
+ final SchemaDefinition schemaDefinition =
schemaDefinition(SAMPLE_PROTO, schemaText);
+
+ final Schema schema = compiler.compileOrGetFromCache(schemaDefinition);
+
+ assertNotNull(schema.getField(SAMPLE_TYPE, FIELD_VALUE));
+ }
+
+ @Test
+ void testCompileReferencedSchemaWithCustomOptions() {
+ final String referencedSchemaText = """
+ syntax = "proto3";
+ package example;
+
+ message Common {
+ int32 id = 1 [(custom.field_meta) = "c"];
+ }
+ """;
+ final String rootSchemaText = """
+ syntax = "proto3";
+ package example;
+
+ import "common.proto";
+
+ message Root {
+ Common item = 1 [(custom.field_meta) = "r"];
+ }
+ """;
+ final SchemaDefinition referencedSchema =
schemaDefinition(COMMON_PROTO, referencedSchemaText);
+ final SchemaDefinition rootSchema = schemaDefinition(SAMPLE_PROTO,
rootSchemaText, Map.of(COMMON_PROTO, referencedSchema));
+
+ final Schema schema = compiler.compileOrGetFromCache(rootSchema);
+
+ final Field id = schema.getField(PACKAGE + ".Common", "id");
+ final Field item = schema.getField(PACKAGE + ".Root", "item");
+
+ assertNotNull(id);
+ assertNoParenthesizedOptions(id.getOptions());
+ assertNotNull(item);
+ assertNoParenthesizedOptions(item.getOptions());
+ }
+
+ @Test
+ void testCompileFailsWhenMessageTypeIsUnresolved() {
+ final String schemaText = """
+ syntax = "proto3";
+ package example;
+
+ message Root {
+ Common item = 1;
+ }
+ """;
+ final SchemaDefinition schemaDefinition =
schemaDefinition(SAMPLE_PROTO, schemaText);
+
+ final RuntimeException exception =
assertThrows(RuntimeException.class, () ->
compiler.compileOrGetFromCache(schemaDefinition));
+
+ assertInstanceOf(SchemaException.class, exception.getCause());
+ }
+
+ @Test
+ void testCompileFailsWhenTypeImportIsMissing() {
+ final String schemaText = """
+ syntax = "proto3";
+ package example;
+
+ import "common.proto";
+
+ message Root {
+ Common item = 1;
+ }
+ """;
+ final SchemaDefinition schemaDefinition =
schemaDefinition(SAMPLE_PROTO, schemaText);
+
+ final RuntimeException exception =
assertThrows(RuntimeException.class, () ->
compiler.compileOrGetFromCache(schemaDefinition));
+
+ assertInstanceOf(SchemaException.class, exception.getCause());
+ }
+
+ @Test
+ void testCompileNestedMessageAndEnumWithCustomOptions() {
+ final String schemaText = """
+ syntax = "proto3";
+ package example;
+
+ message Outer {
+ option (custom.message_meta) = true;
+
+ message Inner {
+ int32 value = 1 [(custom.field_meta) = "y"];
+ }
+
+ Inner inner = 1;
+ }
+
+ enum Color {
+ option (custom.enum_meta) = true;
+ RED = 0 [(custom.enum_value_meta) = "r"];
+ GREEN = 1;
+ }
+ """;
+ final SchemaDefinition schemaDefinition =
schemaDefinition(SAMPLE_PROTO, schemaText);
+
+ final Schema schema = compiler.compileOrGetFromCache(schemaDefinition);
+ final MessageType outer = assertInstanceOf(MessageType.class,
schema.getType(PACKAGE + ".Outer"));
+ final EnumType color = assertInstanceOf(EnumType.class,
schema.getType(PACKAGE + ".Color"));
+ final EnumConstant red = color.constant(ENUM_RED);
+
+ assertNotNull(outer.field("inner"));
+ assertNotNull(schema.getField(PACKAGE + ".Outer.Inner", FIELD_VALUE));
+ assertNotNull(red);
+ assertNotNull(color.constant("GREEN"));
+ assertNoParenthesizedOptions(color.getOptions());
+ assertNoParenthesizedOptions(red.getOptions());
+ }
+
+ @Test
+ void testPayloadExtendDeclarationsArePreserved() {
+ final String schemaText = """
+ syntax = "proto2";
+ package example;
+
+ message Base {
+ extensions 100 to 199;
+ optional string name = 1;
+ }
+
+ extend Base {
+ optional int32 extra = 100;
+ }
+ """;
+ final SchemaDefinition schemaDefinition =
schemaDefinition(SAMPLE_PROTO, schemaText);
+
+ final Schema schema = compiler.compileOrGetFromCache(schemaDefinition);
+ final MessageType base = assertInstanceOf(MessageType.class,
schema.getType(PACKAGE + ".Base"));
+ final ProtoFile protoFile = schema.protoFile(SAMPLE_PROTO);
+
+ assertNotNull(base.field("name"));
+ assertNotNull(protoFile);
+ assertEquals(1, protoFile.getExtendList().size());
+ assertEquals(FIELD_EXTRA,
protoFile.getExtendList().get(0).getFields().get(0).getName());
+ }
+
+ @Test
+ void testCompileRemovesCustomOptionsFromAllSyntaxElements() throws
IOException {
+ final String schemaText =
readTestResource("/org/apache/nifi/protobuf/test/custom_options_on_all_elements.proto");
+ final SchemaDefinition schemaDefinition =
schemaDefinition(SAMPLE_PROTO, schemaText);
+
+ final Schema schema = compiler.compileOrGetFromCache(schemaDefinition);
+
+ final ProtoFile protoFile = schema.protoFile(SAMPLE_PROTO);
+ assertNotNull(protoFile);
+ assertEquals("com.example.comprehensive", protoFile.javaPackage());
+ assertNoParenthesizedOptions(protoFile.getOptions());
+
+ final MessageType wrapper = assertInstanceOf(MessageType.class,
schema.getType(WRAPPER_TYPE));
+ assertNoParenthesizedOptions(wrapper.getOptions());
+
+ final Field flag = schema.getField(WRAPPER_TYPE, "flag");
+ assertNotNull(flag);
+ assertNoParenthesizedOptions(flag.getOptions());
+
+ final OneOf kind = wrapper.oneOf("kind");
+ assertNotNull(kind);
+ assertNoParenthesizedOptions(kind.getOptions());
+
+ final Field text = schema.getField(WRAPPER_TYPE, "text");
+ assertNotNull(text);
+ assertNoParenthesizedOptions(text.getOptions());
+
+ assertEquals(1, protoFile.getExtendList().size());
+ final Field extra =
protoFile.getExtendList().get(0).getFields().get(0);
+ assertEquals(FIELD_EXTRA, extra.getName());
+ assertNoParenthesizedOptions(extra.getOptions());
+
+ assertNotNull(schema.getType(WRAPPER_TYPE + ".Nested"));
+ assertEquals(1, wrapper.getNestedExtendList().size());
+ final Field nestedExtra =
wrapper.getNestedExtendList().get(0).getFields().get(0);
+ assertEquals("nested_extra", nestedExtra.getName());
+ assertNoParenthesizedOptions(nestedExtra.getOptions());
+
+ final EnumType color = assertInstanceOf(EnumType.class,
schema.getType(WRAPPER_TYPE + ".Color"));
+ assertNoParenthesizedOptions(color.getOptions());
+
+ final EnumConstant red = color.constant(ENUM_RED);
+ assertNotNull(red);
+ assertNoParenthesizedOptions(red.getOptions());
+
+ final Service catalog = schema.getService(PACKAGE + ".Catalog");
+ assertNotNull(catalog);
+ assertNoParenthesizedOptions(catalog.options());
+
+ final Rpc lookup = catalog.rpc("Lookup");
+ assertNotNull(lookup);
+ assertEquals(PACKAGE + ".Request", lookup.getRequestType().toString());
+ assertEquals(PACKAGE + ".Response",
lookup.getResponseType().toString());
+ assertNoParenthesizedOptions(lookup.getOptions());
+ }
+
+ private void assertNoParenthesizedOptions(final Options options) {
+ final List<OptionElement> elements = options.getElements();
+ final List<Executable> assertions = new ArrayList<>(elements.size());
+ for (final OptionElement option : elements) {
+ assertions.add(() -> assertFalse(option.isParenthesized()));
+ }
+
+ assertAll(assertions);
+ }
+
+ private String readTestResource(final String resourcePath) throws
IOException {
+ try (InputStream inputStream =
getClass().getResourceAsStream(resourcePath)) {
+ assertNotNull(inputStream, "Test resource not found: " +
resourcePath);
+ return new String(inputStream.readAllBytes(),
StandardCharsets.UTF_8);
+ }
+ }
+
+ private SchemaDefinition schemaDefinition(final String name, final String
schemaText) {
+ return schemaDefinition(name, schemaText, Map.of());
+ }
+
+ private SchemaDefinition schemaDefinition(final String name, final String
schemaText, final Map<String, SchemaDefinition> references) {
+ return new StandardSchemaDefinition(
+ SchemaIdentifier.builder().name(name).build(),
+ schemaText,
+ SchemaDefinition.SchemaType.PROTOBUF,
+ references);
+ }
+}
diff --git
a/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/test/java/org/apache/nifi/services/protobuf/TestStandardProtobufReader.java
b/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/test/java/org/apache/nifi/services/protobuf/TestStandardProtobufReader.java
index 54b36a267b4..edd01f7b5ef 100644
---
a/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/test/java/org/apache/nifi/services/protobuf/TestStandardProtobufReader.java
+++
b/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/test/java/org/apache/nifi/services/protobuf/TestStandardProtobufReader.java
@@ -16,6 +16,10 @@
*/
package org.apache.nifi.services.protobuf;
+import com.google.protobuf.DescriptorProtos.DescriptorProto;
+import com.google.protobuf.DescriptorProtos.FieldDescriptorProto;
+import com.google.protobuf.DescriptorProtos.FileDescriptorProto;
+import com.google.protobuf.Descriptors.FileDescriptor;
import com.google.protobuf.DynamicMessage;
import org.apache.nifi.schema.access.SchemaNotFoundException;
import org.apache.nifi.schemaregistry.services.SchemaDefinition;
@@ -216,6 +220,80 @@ class TestStandardProtobufReader extends
StandardProtobufReaderTestBase {
}
}
+ @Nested
+ class WithCustomOptions {
+
+ private static final String SCHEMA_FILE_NAME = "sample.proto";
+ private static final String PACKAGE_NAME = "example";
+ private static final String MESSAGE_NAME = "Sample";
+ private static final String MESSAGE_TYPE = PACKAGE_NAME + "." +
MESSAGE_NAME;
+ private static final String ENABLED_FIELD = "enabled";
+ private static final String NAME_FIELD = "name";
+ private static final int ENABLED_VALUE = 42;
+ private static final String NAME_VALUE = "banner";
+ private static final String SCHEMA_TEXT = """
+ syntax = "proto3";
+ package example;
+
+ message Sample {
+ int32 enabled = 1 [(confluent.field_meta) = {
+ params: [
+ {
+ key: "connect.type",
+ value: "int16"
+ }
+ ]
+ }];
+ string name = 2;
+ }
+ """;
+
+ @Test
+ void testCreateRecordReaderWithCustomOptionWithoutImport() throws
Exception {
+ mockSchemaRegistry.returnSchemaText(SCHEMA_TEXT);
+
mockSchemaReferenceReader.returnSchemaIdentifierWithName(SCHEMA_FILE_NAME);
+ mockMessageNameResolver.returnMessageName(MESSAGE_TYPE);
+ enableAllControllerServices();
+
+ final InputStream testDataStream =
generateInputDataForSampleWithCustomOption();
+ final RecordReader recordReader =
standardProtobufReader.createRecordReader(emptyMap(), testDataStream, 0L,
runner.getLogger());
+ final Record record = recordReader.nextRecord();
+
+ assertNotNull(recordReader);
+ assertNotNull(record);
+ assertEquals(ENABLED_VALUE, record.getAsInt(ENABLED_FIELD));
+ assertEquals(NAME_VALUE, record.getAsString(NAME_FIELD));
+ }
+
+ private InputStream generateInputDataForSampleWithCustomOption()
throws DescriptorValidationException {
+ final FileDescriptorProto fileDescriptorProto =
FileDescriptorProto.newBuilder()
+ .setName(SCHEMA_FILE_NAME)
+ .setPackage(PACKAGE_NAME)
+ .setSyntax("proto3")
+ .addMessageType(DescriptorProto.newBuilder()
+ .setName(MESSAGE_NAME)
+ .addField(FieldDescriptorProto.newBuilder()
+ .setName(ENABLED_FIELD)
+ .setNumber(1)
+ .setType(FieldDescriptorProto.Type.TYPE_INT32)
+ .build())
+ .addField(FieldDescriptorProto.newBuilder()
+ .setName(NAME_FIELD)
+ .setNumber(2)
+ .setType(FieldDescriptorProto.Type.TYPE_STRING)
+ .build())
+ .build())
+ .build();
+ final FileDescriptor fileDescriptor =
FileDescriptor.buildFrom(fileDescriptorProto, new FileDescriptor[0]);
+ final Descriptor sampleDescriptor =
fileDescriptor.findMessageTypeByName(MESSAGE_NAME);
+ final DynamicMessage sample =
DynamicMessage.newBuilder(sampleDescriptor)
+ .setField(sampleDescriptor.findFieldByName(ENABLED_FIELD),
ENABLED_VALUE)
+ .setField(sampleDescriptor.findFieldByName(NAME_FIELD),
NAME_VALUE)
+ .build();
+ return sample.toByteString().newInput();
+ }
+ }
+
private SchemaDefinition createSchemaDefinition(final String name) {
return createSchemaDefinition(name, "test schema content", new
HashMap<>());
}
diff --git
a/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/test/resources/org/apache/nifi/protobuf/test/custom_options_on_all_elements.proto
b/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/test/resources/org/apache/nifi/protobuf/test/custom_options_on_all_elements.proto
new file mode 100644
index 00000000000..f4ccc8c157c
--- /dev/null
+++
b/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/test/resources/org/apache/nifi/protobuf/test/custom_options_on_all_elements.proto
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+syntax = "proto2";
+package example;
+
+option java_package = "com.example.comprehensive";
+option (custom.file_meta) = "docs";
+
+message Wrapper {
+ option (custom.message_meta) = true;
+
+ optional int32 flag = 1 [(custom.field_meta) = "f"];
+
+ oneof kind {
+ option (custom.oneof_meta) = true;
+ string text = 2 [(custom.oneof_field_meta) = "t"];
+ int32 number = 3;
+ }
+
+ extensions 100 to 199 [(custom.extension_range_meta) = true];
+
+ message Nested {
+ extensions 200 to 299;
+ }
+
+ extend Nested {
+ optional int32 nested_extra = 200 [(custom.nested_extend_field_meta) =
"n"];
+ }
+
+ enum Color {
+ option (custom.enum_meta) = true;
+ RED = 0 [(custom.enum_value_meta) = "r"];
+ GREEN = 1;
+ }
+}
+
+extend Wrapper {
+ optional int32 extra = 100 [(custom.extend_field_meta) = "e"];
+}
+
+message Request {
+}
+
+message Response {
+}
+
+service Catalog {
+ option (custom.service_meta) = true;
+ rpc Lookup (Request) returns (Response) {
+ option (custom.method_meta) = true;
+ }
+}
diff --git
a/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/test/resources/org/apache/nifi/protobuf/test/nested_wrapper_with_field_meta.proto
b/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/test/resources/org/apache/nifi/protobuf/test/nested_wrapper_with_field_meta.proto
new file mode 100644
index 00000000000..c8bab2132f2
--- /dev/null
+++
b/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/test/resources/org/apache/nifi/protobuf/test/nested_wrapper_with_field_meta.proto
@@ -0,0 +1,72 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+syntax = "proto3";
+package example;
+
+message Wrapper {
+ optional Payload previous = 1;
+ optional Payload current = 2;
+ Origin origin = 3;
+ string operation = 4;
+ optional int64 event_time = 5;
+ optional chunk grouping = 6;
+
+ message Payload {
+ int64 record_id = 1;
+ string display_name = 2;
+ optional string code = 3;
+ int32 flag_a = 4 [(confluent.field_meta) = {
+ params: [
+ {
+ key: "connect.type",
+ value: "int16"
+ }
+ ]
+ }];
+ optional int32 flag_b = 5 [(confluent.field_meta) = {
+ params: [
+ {
+ key: "connect.type",
+ value: "int16"
+ }
+ ]
+ }];
+ int32 flag_c = 6 [(confluent.field_meta) = {
+ params: [
+ {
+ key: "connect.type",
+ value: "int16"
+ }
+ ]
+ }];
+ string first_seen = 7;
+ optional string last_editor = 8;
+ }
+ message Origin {
+ string schema_version = 1;
+ string producer = 2;
+ int64 origin_time = 3;
+ optional string mode = 4;
+ string catalog = 5;
+ optional string relation = 6;
+ }
+ message chunk {
+ string grouping_id = 1;
+ int64 sequence_a = 2;
+ int64 sequence_b = 3;
+ }
+}