diff --git a/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-platform-nar/pom.xml b/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-platform-nar/pom.xml index c47e0d305389..3b606d9a40e8 100644 --- a/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-platform-nar/pom.xml +++ b/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-platform-nar/pom.xml @@ -35,5 +35,10 @@ nifi-confluent-protobuf-message-name-resolver ${project.version} + + org.apache.nifi + nifi-confluent-protobuf-message-index-writer + ${project.version} + diff --git a/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-message-name-resolver/src/main/java/org/apache/nifi/confluent/schemaregistry/VarintUtils.java b/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-antlr-parser/src/main/java/org/apache/nifi/confluent/schema/VarintUtils.java similarity index 92% rename from nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-message-name-resolver/src/main/java/org/apache/nifi/confluent/schemaregistry/VarintUtils.java rename to nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-antlr-parser/src/main/java/org/apache/nifi/confluent/schema/VarintUtils.java index 209a5e9864c6..44b47bc40108 100644 --- a/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-message-name-resolver/src/main/java/org/apache/nifi/confluent/schemaregistry/VarintUtils.java +++ b/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-antlr-parser/src/main/java/org/apache/nifi/confluent/schema/VarintUtils.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.nifi.confluent.schemaregistry; +package org.apache.nifi.confluent.schema; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -38,7 +38,7 @@ * Protocol Buffers Encoding

* */ -final class VarintUtils { +public final class VarintUtils { private VarintUtils() { } @@ -50,7 +50,7 @@ private VarintUtils() { * @return the decoded varint value * @throws IOException if unable to read from stream or invalid varint format */ - static int readVarintFromStream(final InputStream inputStream) throws IOException { + public static int readVarintFromStream(final InputStream inputStream) throws IOException { final int firstByte = inputStream.read(); if (firstByte == -1) { throw new IOException("Unexpected end of stream while reading varint"); @@ -66,7 +66,7 @@ static int readVarintFromStream(final InputStream inputStream) throws IOExceptio * @return the decoded varint value * @throws IOException if unable to read from stream or invalid varint format */ - static int readVarintFromStreamAfterFirstByteConsumed(final InputStream inputStream, final int firstByte) throws IOException { + public static int readVarintFromStreamAfterFirstByteConsumed(final InputStream inputStream, final int firstByte) throws IOException { // accumulated result int value = 0; @@ -115,7 +115,7 @@ static int readVarintFromStreamAfterFirstByteConsumed(final InputStream inputStr * @param encodedValue the zigzag encoded value * @return the decoded integer value */ - static int decodeZigZag(final int encodedValue) { + public static int decodeZigZag(final int encodedValue) { return (encodedValue >>> 1) ^ -(encodedValue & 1); } @@ -127,7 +127,7 @@ static int decodeZigZag(final int encodedValue) { * @param value the integer value to encode * @return byte array containing the zigzag encoded varint */ - static byte[] writeZigZagVarint(final int value) { + public static byte[] writeZigZagVarint(final int value) { final ByteArrayOutputStream output = new ByteArrayOutputStream(4); // Zigzag encode diff --git a/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-message-name-resolver/src/test/java/org/apache/nifi/confluent/schemaregistry/VarintUtilsTest.java b/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-antlr-parser/src/test/java/org/apache/nifi/confluent/schema/VarintUtilsTest.java similarity index 95% rename from nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-message-name-resolver/src/test/java/org/apache/nifi/confluent/schemaregistry/VarintUtilsTest.java rename to nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-antlr-parser/src/test/java/org/apache/nifi/confluent/schema/VarintUtilsTest.java index 0c343864e712..71bc97178f9c 100644 --- a/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-message-name-resolver/src/test/java/org/apache/nifi/confluent/schemaregistry/VarintUtilsTest.java +++ b/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-antlr-parser/src/test/java/org/apache/nifi/confluent/schema/VarintUtilsTest.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.nifi.confluent.schemaregistry; +package org.apache.nifi.confluent.schema; import org.junit.jupiter.api.Test; @@ -23,10 +23,10 @@ import java.io.IOException; import java.io.InputStream; -import static org.apache.nifi.confluent.schemaregistry.VarintUtils.decodeZigZag; -import static org.apache.nifi.confluent.schemaregistry.VarintUtils.readVarintFromStream; -import static org.apache.nifi.confluent.schemaregistry.VarintUtils.readVarintFromStreamAfterFirstByteConsumed; -import static org.apache.nifi.confluent.schemaregistry.VarintUtils.writeZigZagVarint; +import static org.apache.nifi.confluent.schema.VarintUtils.decodeZigZag; +import static org.apache.nifi.confluent.schema.VarintUtils.readVarintFromStream; +import static org.apache.nifi.confluent.schema.VarintUtils.readVarintFromStreamAfterFirstByteConsumed; +import static org.apache.nifi.confluent.schema.VarintUtils.writeZigZagVarint; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; diff --git a/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-message-index-writer/pom.xml b/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-message-index-writer/pom.xml new file mode 100644 index 000000000000..e5cffd6f329d --- /dev/null +++ b/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-message-index-writer/pom.xml @@ -0,0 +1,57 @@ + + + + 4.0.0 + + + org.apache.nifi + nifi-confluent-platform-bundle + 2.12.0-SNAPSHOT + + + nifi-confluent-protobuf-message-index-writer + jar + Confluent Protobuf Message Index Writer for NiFi + + + + org.apache.nifi + nifi-schema-registry-service-api + + + org.apache.nifi + nifi-confluent-platform-schema-api + ${project.version} + + + org.apache.nifi + nifi-confluent-protobuf-antlr-parser + ${project.version} + + + com.github.ben-manes.caffeine + caffeine + + + + + org.apache.nifi + nifi-record + test + + + org.apache.nifi + nifi-mock + test + + + diff --git a/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-message-index-writer/src/main/java/org/apache/nifi/confluent/schemaregistry/ConfluentProtobufMessageIndexWriter.java b/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-message-index-writer/src/main/java/org/apache/nifi/confluent/schemaregistry/ConfluentProtobufMessageIndexWriter.java new file mode 100644 index 000000000000..ea62ad4ef393 --- /dev/null +++ b/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-message-index-writer/src/main/java/org/apache/nifi/confluent/schemaregistry/ConfluentProtobufMessageIndexWriter.java @@ -0,0 +1,84 @@ +/* + * 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.confluent.schemaregistry; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import org.apache.nifi.annotation.documentation.CapabilityDescription; +import org.apache.nifi.annotation.documentation.Tags; +import org.apache.nifi.annotation.lifecycle.OnDisabled; +import org.apache.nifi.annotation.lifecycle.OnEnabled; +import org.apache.nifi.confluent.schema.AntlrProtobufMessageSchemaParser; +import org.apache.nifi.confluent.schema.ProtobufMessageSchema; +import org.apache.nifi.controller.AbstractControllerService; +import org.apache.nifi.controller.ConfigurationContext; +import org.apache.nifi.schemaregistry.services.MessageIndexWriter; +import org.apache.nifi.schemaregistry.services.MessageName; +import org.apache.nifi.schemaregistry.services.SchemaDefinition; + +import java.io.IOException; +import java.io.OutputStream; +import java.time.Duration; +import java.util.List; +import java.util.Map; + +@Tags({"confluent", "schema", "registry", "protobuf", "message", "index", "writer"}) +@CapabilityDescription(""" + Writes Protobuf message index information in Confluent Schema Registry wire format by encoding the declaration-order path to a given message name within the schema definition. + For Confluent wire format reference see: https://docs.confluent.io/platform/current/schema-registry/fundamentals/serdes-develop/index.html#wire-format + """) +public class ConfluentProtobufMessageIndexWriter extends AbstractControllerService implements MessageIndexWriter { + + private static final int MAXIMUM_CACHE_SIZE = 1000; + private static final int CACHE_EXPIRE_HOURS = 1; + + private Cache messageIndexCache; + + @OnEnabled + public void onEnabled(final ConfigurationContext context) { + messageIndexCache = Caffeine.newBuilder().maximumSize(MAXIMUM_CACHE_SIZE).expireAfterWrite(Duration.ofHours(CACHE_EXPIRE_HOURS)).build(); + } + + @OnDisabled + public void onDisabled(final ConfigurationContext context) { + if (messageIndexCache != null) { + messageIndexCache.invalidateAll(); + messageIndexCache = null; + } + } + + @Override + public void writeMessageIndex(final Map variables, final SchemaDefinition schemaDefinition, final MessageName messageName, final OutputStream outputStream) throws IOException { + final EncodeMessageIndexArguments encodeMessageIndexArguments = new EncodeMessageIndexArguments(schemaDefinition, messageName); + final byte[] encodedMessageIndex = messageIndexCache.get(encodeMessageIndexArguments, this::encodeMessageIndex); + outputStream.write(encodedMessageIndex); + } + + private byte[] encodeMessageIndex(final EncodeMessageIndexArguments encodeMessageIndexArguments) { + try { + final String schemaText = encodeMessageIndexArguments.schemaDefinition().getText(); + final AntlrProtobufMessageSchemaParser parser = new AntlrProtobufMessageSchemaParser(); + final List rootMessages = parser.parse(schemaText); + return ProtobufMessageIndexEncoder.encode(rootMessages, encodeMessageIndexArguments.messageName()); + } catch (final Exception e) { + throw new IllegalStateException("Failed to parse protobuf schema", e); + } + } + + private record EncodeMessageIndexArguments(SchemaDefinition schemaDefinition, MessageName messageName) { + } +} diff --git a/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-message-index-writer/src/main/java/org/apache/nifi/confluent/schemaregistry/ProtobufMessageIndexEncoder.java b/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-message-index-writer/src/main/java/org/apache/nifi/confluent/schemaregistry/ProtobufMessageIndexEncoder.java new file mode 100644 index 000000000000..1f29ecdb6c53 --- /dev/null +++ b/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-message-index-writer/src/main/java/org/apache/nifi/confluent/schemaregistry/ProtobufMessageIndexEncoder.java @@ -0,0 +1,113 @@ +/* + * 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.confluent.schemaregistry; + +import org.apache.nifi.confluent.schema.ProtobufMessageSchema; +import org.apache.nifi.confluent.schema.VarintUtils; +import org.apache.nifi.schemaregistry.services.MessageName; + +import java.io.ByteArrayOutputStream; +import java.util.ArrayList; +import java.util.List; + +import static java.lang.String.format; + +/** + * Computes the Confluent wire format message index path for a target message within a parsed + * Protobuf schema, and encodes it to bytes. This is the inverse of the message index decoding + * performed by {@code ConfluentProtobufMessageNameResolver}: given a fully qualified message + * name, it locates the path of declaration-order indexes leading to that message and encodes it + * as zigzag varints, applying the single-byte {@code 0x00} optimization for the common case of + * the first root message. + *

+ * See the Confluent protobuf wire format. + *

+ * This class has no dependency on the NiFi framework and can be exercised directly with plain + * unit tests. + */ +final class ProtobufMessageIndexEncoder { + + private static final byte[] FIRST_ROOT_MESSAGE_INDEX = {0x00}; + + private ProtobufMessageIndexEncoder() { + } + + /** + * Encodes the message index path for the given message name within the given schema. + * + * @param rootMessages the root messages of the parsed Protobuf schema, in declaration order + * @param messageName the target message name to locate + * @return the encoded message index bytes + * @throws IllegalStateException if the message name cannot be located within the schema + */ + static byte[] encode(final List rootMessages, final MessageName messageName) { + final List messageIndexPath = findMessageIndexPath(rootMessages, messageName); + + if (messageIndexPath.size() == 1 && messageIndexPath.getFirst() == 0) { + return FIRST_ROOT_MESSAGE_INDEX; + } + + final ByteArrayOutputStream output = new ByteArrayOutputStream(); + output.writeBytes(VarintUtils.writeZigZagVarint(messageIndexPath.size())); + for (final int index : messageIndexPath) { + output.writeBytes(VarintUtils.writeZigZagVarint(index)); + } + return output.toByteArray(); + } + + private static List findMessageIndexPath(final List rootMessages, final MessageName messageName) { + // Match on the fully qualified name across the message tree rather than on the namespace/name split, so the + // encoder is robust to how the MessageName was constructed (a statically configured "package.Outer.Nested" + // and a resolver-produced name both reduce to the same fully qualified name). + final String targetFullyQualifiedName = messageName.getFullyQualifiedName(); + + for (int rootIndex = 0; rootIndex < rootMessages.size(); rootIndex++) { + final ProtobufMessageSchema rootMessage = rootMessages.get(rootIndex); + final String rootFullyQualifiedName = rootMessage.getPackageName() + .map(packageName -> packageName + "." + rootMessage.getName()) + .orElseGet(rootMessage::getName); + + final List messageIndexPath = descend(rootMessage, rootFullyQualifiedName, targetFullyQualifiedName); + if (messageIndexPath != null) { + messageIndexPath.addFirst(rootIndex); + return messageIndexPath; + } + } + + throw new IllegalStateException(format("Message not found in schema definition: %s", targetFullyQualifiedName)); + } + + private static List descend(final ProtobufMessageSchema currentMessage, final String currentFullyQualifiedName, final String targetFullyQualifiedName) { + if (currentFullyQualifiedName.equals(targetFullyQualifiedName)) { + return new ArrayList<>(); + } + + final List children = currentMessage.getChildMessageSchemas(); + for (int childIndex = 0; childIndex < children.size(); childIndex++) { + final ProtobufMessageSchema child = children.get(childIndex); + final String childFullyQualifiedName = currentFullyQualifiedName + "." + child.getName(); + + final List messageIndexPath = descend(child, childFullyQualifiedName, targetFullyQualifiedName); + if (messageIndexPath != null) { + messageIndexPath.addFirst(childIndex); + return messageIndexPath; + } + } + + return null; + } +} diff --git a/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-message-index-writer/src/main/resources/META-INF/services/org.apache.nifi.controller.ControllerService b/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-message-index-writer/src/main/resources/META-INF/services/org.apache.nifi.controller.ControllerService new file mode 100644 index 000000000000..7c9bba672984 --- /dev/null +++ b/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-message-index-writer/src/main/resources/META-INF/services/org.apache.nifi.controller.ControllerService @@ -0,0 +1,16 @@ +# 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. + +org.apache.nifi.confluent.schemaregistry.ConfluentProtobufMessageIndexWriter diff --git a/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-message-index-writer/src/test/java/org/apache/nifi/confluent/schemaregistry/ConfluentProtobufMessageIndexWriterTest.java b/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-message-index-writer/src/test/java/org/apache/nifi/confluent/schemaregistry/ConfluentProtobufMessageIndexWriterTest.java new file mode 100644 index 000000000000..d8de2699ca32 --- /dev/null +++ b/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-message-index-writer/src/test/java/org/apache/nifi/confluent/schemaregistry/ConfluentProtobufMessageIndexWriterTest.java @@ -0,0 +1,111 @@ +/* + * 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.confluent.schemaregistry; + +import org.apache.nifi.schemaregistry.services.MessageName; +import org.apache.nifi.schemaregistry.services.SchemaDefinition; +import org.apache.nifi.schemaregistry.services.StandardMessageName; +import org.apache.nifi.schemaregistry.services.StandardSchemaDefinition; +import org.apache.nifi.serialization.record.SchemaIdentifier; +import org.apache.nifi.util.NoOpProcessor; +import org.apache.nifi.util.TestRunner; +import org.apache.nifi.util.TestRunners; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; + +import static org.apache.nifi.schemaregistry.services.SchemaDefinition.SchemaType.PROTOBUF; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ConfluentProtobufMessageIndexWriterTest { + + // Schema without package (default package) + private static final String DEFAULT_PACKAGE_SCHEMA = """ + syntax = "proto3"; + message User { + int32 id = 1; + string name = 2; + Address address = 3; + message Profile { + string bio = 1; + } + } + message Company { + string name = 1; + } + message Address { + string street = 1; + }"""; + + private static final SchemaIdentifier ID = SchemaIdentifier.builder() + .id(1L) + .build(); + + private static final SchemaDefinition SCHEMA_WITH_DEFAULT_PACKAGE = new StandardSchemaDefinition( + ID, + DEFAULT_PACKAGE_SCHEMA, + PROTOBUF, + Map.of()); + + private ConfluentProtobufMessageIndexWriter writer; + + private static Stream provideMessageIndexTestCases() { + return Stream.of( + // the format is [target message name], [expected wire-format bytes for the message index] + Arguments.of(new StandardMessageName(Optional.empty(), "User"), new byte[] {0x00}), + Arguments.of(new StandardMessageName(Optional.empty(), "Company"), new byte[] {0x02, 0x02}), + Arguments.of(new StandardMessageName(Optional.empty(), "Address"), new byte[] {0x02, 0x04}), + Arguments.of(new StandardMessageName(Optional.empty(), "User.Profile"), new byte[] {0x04, 0x00, 0x00}) + ); + } + + @BeforeEach + void setUp() throws Exception { + writer = new ConfluentProtobufMessageIndexWriter(); + final TestRunner testRunner = TestRunners.newTestRunner(NoOpProcessor.class); + testRunner.addControllerService("messageIndexWriter", writer); + testRunner.enableControllerService(writer); + } + + @ParameterizedTest + @MethodSource("provideMessageIndexTestCases") + void testWriteMessageIndex(final MessageName messageName, final byte[] expectedBytes) throws IOException { + final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + writer.writeMessageIndex(Map.of(), SCHEMA_WITH_DEFAULT_PACKAGE, messageName, outputStream); + + assertArrayEquals(expectedBytes, outputStream.toByteArray()); + } + + @Test + void testWriteMessageIndexUnknownMessageThrows() { + final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + final MessageName unknownMessageName = new StandardMessageName(Optional.empty(), "DoesNotExist"); + + assertThrows(IllegalStateException.class, + () -> writer.writeMessageIndex(Map.of(), SCHEMA_WITH_DEFAULT_PACKAGE, unknownMessageName, outputStream)); + } +} diff --git a/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-message-index-writer/src/test/java/org/apache/nifi/confluent/schemaregistry/ProtobufMessageIndexEncoderTest.java b/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-message-index-writer/src/test/java/org/apache/nifi/confluent/schemaregistry/ProtobufMessageIndexEncoderTest.java new file mode 100644 index 000000000000..12ddde4e9eee --- /dev/null +++ b/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-message-index-writer/src/test/java/org/apache/nifi/confluent/schemaregistry/ProtobufMessageIndexEncoderTest.java @@ -0,0 +1,195 @@ +/* + * 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.confluent.schemaregistry; + +import org.apache.nifi.confluent.schema.AntlrProtobufMessageSchemaParser; +import org.apache.nifi.confluent.schema.ProtobufMessageSchema; +import org.apache.nifi.confluent.schema.VarintUtils; +import org.apache.nifi.schemaregistry.services.MessageName; +import org.apache.nifi.schemaregistry.services.StandardMessageName; +import org.apache.nifi.schemaregistry.services.StandardMessageNameFactory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * This class contains no NiFi framework dependencies: it exercises {@link ProtobufMessageIndexEncoder} + * directly, without a TestRunner or any controller-service context. + */ +class ProtobufMessageIndexEncoderTest { + + // Schema without package (default package) + private static final String DEFAULT_PACKAGE_SCHEMA = """ + syntax = "proto3"; + message User { + int32 id = 1; + string name = 2; + Address address = 3; + message Profile { + string bio = 1; + Settings settings = 2; + message Settings { + bool notifications = 1; + string theme = 2; + } + } + } + message Company { + string name = 1; + Address address = 2; + } + message Address { + string street = 1; + string city = 2; + }"""; + + // Schema with explicit package + private static final String EXPLICIT_PACKAGE_SCHEMA = """ + syntax = "proto3"; + package com.example.proto; + message User { + int32 id = 1; + string name = 2; + Address address = 3; + message Profile { + string bio = 1; + Settings settings = 2; + message Settings { + bool notifications = 1; + string theme = 2; + } + } + } + message Company { + string name = 1; + Address address = 2; + } + message Address { + string street = 1; + string city = 2; + }"""; + + private static Stream provideMessageIndexTestCases() { + return Stream.of( + // the format is [input schema], [target message name], [expected message indexes] + Arguments.of(DEFAULT_PACKAGE_SCHEMA, new StandardMessageName(Optional.empty(), "User"), new int[] {0}), + Arguments.of(DEFAULT_PACKAGE_SCHEMA, new StandardMessageName(Optional.empty(), "Company"), new int[] {1}), + Arguments.of(DEFAULT_PACKAGE_SCHEMA, new StandardMessageName(Optional.empty(), "Address"), new int[] {2}), + Arguments.of(DEFAULT_PACKAGE_SCHEMA, new StandardMessageName(Optional.empty(), "User.Profile"), new int[] {0, 0}), + Arguments.of(DEFAULT_PACKAGE_SCHEMA, new StandardMessageName(Optional.empty(), "User.Profile.Settings"), new int[] {0, 0, 0}), + + Arguments.of(EXPLICIT_PACKAGE_SCHEMA, new StandardMessageName(Optional.of("com.example.proto"), "User"), new int[] {0}), + Arguments.of(EXPLICIT_PACKAGE_SCHEMA, new StandardMessageName(Optional.of("com.example.proto"), "Company"), new int[] {1}), + Arguments.of(EXPLICIT_PACKAGE_SCHEMA, new StandardMessageName(Optional.of("com.example.proto"), "Address"), new int[] {2}), + Arguments.of(EXPLICIT_PACKAGE_SCHEMA, new StandardMessageName(Optional.of("com.example.proto"), "User.Profile"), new int[] {0, 0}), + Arguments.of(EXPLICIT_PACKAGE_SCHEMA, new StandardMessageName(Optional.of("com.example.proto"), "User.Profile.Settings"), new int[] {0, 0, 0}) + ); + } + + @ParameterizedTest + @MethodSource("provideMessageIndexTestCases") + void testEncode(final String schemaText, final MessageName messageName, final int[] expectedIndexes) throws IOException { + final List rootMessages = new AntlrProtobufMessageSchemaParser().parse(schemaText); + + final byte[] encoded = ProtobufMessageIndexEncoder.encode(rootMessages, messageName); + + assertEquals(IntStream.of(expectedIndexes).boxed().toList(), decodeIndexes(encoded)); + } + + @Test + void testEncodeFirstRootMessageUsesSingleByteOptimization() { + final List rootMessages = new AntlrProtobufMessageSchemaParser().parse(DEFAULT_PACKAGE_SCHEMA); + + final byte[] encoded = ProtobufMessageIndexEncoder.encode(rootMessages, new StandardMessageName(Optional.empty(), "User")); + + assertArrayEquals(new byte[] {0x00}, encoded); + } + + @Test + void testEncodeMessageNotFoundThrows() { + final List rootMessages = new AntlrProtobufMessageSchemaParser().parse(DEFAULT_PACKAGE_SCHEMA); + + assertThrows(IllegalStateException.class, + () -> ProtobufMessageIndexEncoder.encode(rootMessages, new StandardMessageName(Optional.empty(), "DoesNotExist"))); + } + + @Test + void testEncodeNamespaceMismatchThrows() { + final List rootMessages = new AntlrProtobufMessageSchemaParser().parse(EXPLICIT_PACKAGE_SCHEMA); + + assertThrows(IllegalStateException.class, + () -> ProtobufMessageIndexEncoder.encode(rootMessages, new StandardMessageName(Optional.empty(), "User"))); + } + + @Test + void testEncodeNestedMessageFromFactorySplitName() throws IOException { + // StandardMessageNameFactory.fromName is what the writer's static Message Name property uses. For a nested + // message it splits "User.Profile" into namespace="User"/name="Profile" - the encoder must still resolve it + // to the nested index [0, 0] by matching on the fully qualified name. + final List rootMessages = new AntlrProtobufMessageSchemaParser().parse(DEFAULT_PACKAGE_SCHEMA); + + final byte[] encoded = ProtobufMessageIndexEncoder.encode(rootMessages, StandardMessageNameFactory.fromName("User.Profile")); + + assertEquals(List.of(0, 0), decodeIndexes(encoded)); + } + + @Test + void testEncodeNestedMessageWithPackageFromFactorySplitName() throws IOException { + // With a package, fromName splits "com.example.proto.User.Profile" into namespace="com.example.proto.User"/ + // name="Profile"; the fully qualified name still resolves to the nested index [0, 0]. + final List rootMessages = new AntlrProtobufMessageSchemaParser().parse(EXPLICIT_PACKAGE_SCHEMA); + + final byte[] encoded = ProtobufMessageIndexEncoder.encode(rootMessages, StandardMessageNameFactory.fromName("com.example.proto.User.Profile")); + + assertEquals(List.of(0, 0), decodeIndexes(encoded)); + } + + /** + * Decodes message indexes according to Confluent wire format, mirroring the read side, to + * verify what {@link ProtobufMessageIndexEncoder} produced can be decoded back correctly. + */ + private List decodeIndexes(final byte[] encoded) throws IOException { + final ByteArrayInputStream inputStream = new ByteArrayInputStream(encoded); + final int firstByte = inputStream.read(); + if (firstByte == 0) { + return List.of(0); + } + + int arrayLength = VarintUtils.readVarintFromStreamAfterFirstByteConsumed(inputStream, firstByte); + arrayLength = VarintUtils.decodeZigZag(arrayLength); + + final List indexes = new ArrayList<>(); + for (int i = 0; i < arrayLength; i++) { + final int rawIndex = VarintUtils.readVarintFromStream(inputStream); + indexes.add(VarintUtils.decodeZigZag(rawIndex)); + } + return indexes; + } +} diff --git a/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-message-name-resolver/src/main/java/org/apache/nifi/confluent/schemaregistry/ConfluentProtobufMessageNameResolver.java b/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-message-name-resolver/src/main/java/org/apache/nifi/confluent/schemaregistry/ConfluentProtobufMessageNameResolver.java index 25bb3565aa3d..67452f540791 100644 --- a/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-message-name-resolver/src/main/java/org/apache/nifi/confluent/schemaregistry/ConfluentProtobufMessageNameResolver.java +++ b/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-message-name-resolver/src/main/java/org/apache/nifi/confluent/schemaregistry/ConfluentProtobufMessageNameResolver.java @@ -42,9 +42,9 @@ import static java.lang.String.format; import static java.util.Collections.singletonList; -import static org.apache.nifi.confluent.schemaregistry.VarintUtils.decodeZigZag; -import static org.apache.nifi.confluent.schemaregistry.VarintUtils.readVarintFromStream; -import static org.apache.nifi.confluent.schemaregistry.VarintUtils.readVarintFromStreamAfterFirstByteConsumed; +import static org.apache.nifi.confluent.schema.VarintUtils.decodeZigZag; +import static org.apache.nifi.confluent.schema.VarintUtils.readVarintFromStream; +import static org.apache.nifi.confluent.schema.VarintUtils.readVarintFromStreamAfterFirstByteConsumed; @Tags({"confluent", "schema", "registry", "protobuf", "message", "name", "resolver"}) @CapabilityDescription(""" diff --git a/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-message-name-resolver/src/test/java/org/apache/nifi/confluent/schemaregistry/ConfluentProtobufMessageNameResolverTest.java b/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-message-name-resolver/src/test/java/org/apache/nifi/confluent/schemaregistry/ConfluentProtobufMessageNameResolverTest.java index 1c8aeb67a7d7..50bca7695f06 100644 --- a/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-message-name-resolver/src/test/java/org/apache/nifi/confluent/schemaregistry/ConfluentProtobufMessageNameResolverTest.java +++ b/nifi-extension-bundles/nifi-confluent-platform-bundle/nifi-confluent-protobuf-message-name-resolver/src/test/java/org/apache/nifi/confluent/schemaregistry/ConfluentProtobufMessageNameResolverTest.java @@ -39,7 +39,7 @@ import java.util.Map; import java.util.stream.Stream; -import static org.apache.nifi.confluent.schemaregistry.VarintUtils.writeZigZagVarint; +import static org.apache.nifi.confluent.schema.VarintUtils.writeZigZagVarint; import static org.apache.nifi.schemaregistry.services.SchemaDefinition.SchemaType.PROTOBUF; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; diff --git a/nifi-extension-bundles/nifi-confluent-platform-bundle/pom.xml b/nifi-extension-bundles/nifi-confluent-platform-bundle/pom.xml index e6c3d4d12c96..2f915ef53f68 100644 --- a/nifi-extension-bundles/nifi-confluent-platform-bundle/pom.xml +++ b/nifi-extension-bundles/nifi-confluent-platform-bundle/pom.xml @@ -24,6 +24,7 @@ nifi-confluent-schema-registry-service nifi-confluent-protobuf-message-name-resolver + nifi-confluent-protobuf-message-index-writer nifi-confluent-platform-nar nifi-confluent-protobuf-antlr-parser nifi-confluent-platform-schema-api diff --git a/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/pom.xml b/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/pom.xml index 96f3cce314b5..25aba10922db 100644 --- a/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/pom.xml +++ b/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/pom.xml @@ -75,6 +75,25 @@ nifi-mock test + + + org.apache.nifi + nifi-confluent-schema-registry-service + ${project.version} + test + + + org.apache.nifi + nifi-confluent-protobuf-message-index-writer + ${project.version} + test + + + org.apache.nifi + nifi-confluent-protobuf-message-name-resolver + ${project.version} + test + 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 da4f20eac556..b5346016f067 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 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; @@ -43,7 +44,7 @@ import static java.nio.file.StandardOpenOption.CREATE; import static java.nio.file.StandardOpenOption.TRUNCATE_EXISTING; import static java.nio.file.StandardOpenOption.WRITE; -import static org.apache.nifi.services.protobuf.ProtobufSchemaValidator.validateSchemaDefinitionIdentifiers; +import static org.apache.nifi.services.protobuf.ProtobufSchemaValidator.validateSchemaReferencePaths; /** * Handles Protocol Buffer schema compilation, caching, and temporary directory operations. @@ -114,8 +115,8 @@ public Schema compileOrGetFromCache(final SchemaDefinition schemaDefinition) { private Schema compileSchemaDefinition(final SchemaDefinition schemaDefinition) throws IOException { logger.debug("Starting schema compilation for identifier: {}", schemaDefinition.getIdentifier()); - // Validate that all schema identifiers end with .proto extension - validateSchemaDefinitionIdentifiers(schemaDefinition, true); + // Validate that every schema reference is keyed by an import path ending in .proto + validateSchemaReferencePaths(schemaDefinition); return executeWithTemporaryDirectory(tempDir -> { try { @@ -182,8 +183,7 @@ private void safeDeleteDirectory(final Path directory) { } /** - * Writes a schema definition to the temporary directory structure. - * If package name is present, creates the appropriate directory structure. + * Writes a schema definition to the temporary directory structure using the name of its identifier. * * @param tempDir the temporary directory root * @param schemaDefinition the schema definition to write @@ -191,14 +191,32 @@ private void safeDeleteDirectory(final Path directory) { */ private void writeSchemaToTempDirectory(final Path tempDir, final SchemaDefinition schemaDefinition) throws IOException { logger.debug("Writing schema definition to temporary directory. Identifier: {}", schemaDefinition.getIdentifier()); + writeSchemaFile(tempDir, generateSchemaFileName(schemaDefinition), schemaDefinition.getText()); + } + + /** + * Writes schema text to a path relative to the temporary directory, creating any parent directories. + * Import paths may contain directories, such as {@code airlines/ph/cdm/shared.proto}, so the enclosing + * directory structure has to exist before the file is written. + * + * @param tempDir the temporary directory root + * @param relativePath the path of the schema file relative to the temporary directory + * @param schemaText the schema text to write + * @throws IOException if unable to create directories or write files + */ + private void writeSchemaFile(final Path tempDir, final String relativePath, final String schemaText) throws IOException { + final Path schemaFile = tempDir.resolve(relativePath).normalize(); + if (!schemaFile.startsWith(tempDir)) { + throw new IOException("Schema file path is not contained in the schema directory: " + relativePath); + } - final String schemaFileName = generateSchemaFileName(schemaDefinition); - final Path schemaFile = tempDir.resolve(schemaFileName); + final Path parentDirectory = schemaFile.getParent(); + if (parentDirectory != null) { + Files.createDirectories(parentDirectory); + } - // Write schema text to file - Files.write(schemaFile, schemaDefinition.getText().getBytes(), CREATE, WRITE, TRUNCATE_EXISTING); - logger.debug("Successfully wrote schema to file: {} (string length: {})", - schemaFile, schemaDefinition.getText().length()); + Files.write(schemaFile, schemaText.getBytes(StandardCharsets.UTF_8), CREATE, WRITE, TRUNCATE_EXISTING); + logger.debug("Successfully wrote schema to file: {} (string length: {})", schemaFile, schemaText.length()); } /** @@ -230,8 +248,10 @@ private void processSchemaReferences(final Path tempDir, final Map + * A reference is keyed by the path used in the import statement of the referencing schema, for example + * {@code airlines/ph/cdm/shared.proto}, and the referenced schema is written to exactly that path so the import + * resolves. The extension is required because the compiler only discovers files named {@code *.proto}; without it + * the schema would fail to compile later with an unresolved import that does not indicate the cause. + *

+ * The identifier of a referenced schema is deliberately not validated. It carries the subject the schema is + * registered under, which is unrelated to the import path and legitimately has no .proto suffix. Under the + * Confluent RecordNameStrategy, for instance, a subject is a fully qualified record name. * - * @param schemaDefinition the schema definition to validate - * @param isRootSchemaDefinition set to true if schema definition is a root definition, false otherwise - * @throws IllegalArgumentException if any identifier does not end with .proto extension + * @param schemaDefinition the schema definition whose references should be validated + * @throws IllegalArgumentException if any reference is keyed by a path that does not end in .proto */ - static void validateSchemaDefinitionIdentifiers(final SchemaDefinition schemaDefinition, final boolean isRootSchemaDefinition) { - // do not validate schema identifier names for root schema definitions. They might be coming from sources like text fields, - // flow file attributes and other sources that do not support naming. - if (!isRootSchemaDefinition) { - validateSchemaIdentifier(schemaDefinition.getIdentifier()); - } - - // Recursively validate all referenced schemas - // schema references have to end with .proto extension. - for (final SchemaDefinition referencedSchema : schemaDefinition.getReferences().values()) { - validateSchemaDefinitionIdentifiers(referencedSchema, false); + static void validateSchemaReferencePaths(final SchemaDefinition schemaDefinition) { + for (final Map.Entry reference : schemaDefinition.getReferences().entrySet()) { + validateReferencePath(reference.getKey()); + validateSchemaReferencePaths(reference.getValue()); } } - /** - * Validates that a single SchemaIdentifier has a name ending with .proto extension. - * - * @param schemaIdentifier the schema identifier to validate - * @throws IllegalArgumentException if the identifier name does not end with .proto extension - */ - private static void validateSchemaIdentifier(final SchemaIdentifier schemaIdentifier) { - schemaIdentifier.getName() - .filter(name -> name.endsWith(".proto")) - .orElseThrow(() -> new IllegalArgumentException("Schema identifier must have a name that ends with .proto extension. Schema identifier: " + schemaIdentifier)); + private static void validateReferencePath(final String referencePath) { + if (referencePath == null || !referencePath.endsWith(PROTO_EXTENSION)) { + throw new IllegalArgumentException( + "Schema reference must be keyed by the import path of the referenced schema, ending with the .proto extension. Schema reference: " + referencePath); + } } } diff --git a/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/main/java/org/apache/nifi/services/protobuf/StandardProtobufWriter.java b/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/main/java/org/apache/nifi/services/protobuf/StandardProtobufWriter.java new file mode 100644 index 000000000000..2344f565b588 --- /dev/null +++ b/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/main/java/org/apache/nifi/services/protobuf/StandardProtobufWriter.java @@ -0,0 +1,356 @@ +/* + * 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.Schema; +import org.apache.nifi.annotation.documentation.CapabilityDescription; +import org.apache.nifi.annotation.documentation.Tags; +import org.apache.nifi.annotation.lifecycle.OnEnabled; +import org.apache.nifi.components.AllowableValue; +import org.apache.nifi.components.DescribedValue; +import org.apache.nifi.components.PropertyDescriptor; +import org.apache.nifi.components.PropertyValue; +import org.apache.nifi.context.PropertyContext; +import org.apache.nifi.controller.AbstractControllerService; +import org.apache.nifi.controller.ConfigurationContext; +import org.apache.nifi.controller.ControllerServiceInitializationContext; +import org.apache.nifi.logging.ComponentLog; +import org.apache.nifi.processor.util.StandardValidators; +import org.apache.nifi.reporting.InitializationException; +import org.apache.nifi.schema.access.SchemaNotFoundException; +import org.apache.nifi.schemaregistry.services.MessageIndexWriter; +import org.apache.nifi.schemaregistry.services.MessageName; +import org.apache.nifi.schemaregistry.services.MessageNameResolver; +import org.apache.nifi.schemaregistry.services.SchemaDefinition; +import org.apache.nifi.schemaregistry.services.SchemaReferenceWriter; +import org.apache.nifi.schemaregistry.services.SchemaRegistry; +import org.apache.nifi.schemaregistry.services.StandardMessageNameFactory; +import org.apache.nifi.schemaregistry.services.StandardSchemaDefinition; +import org.apache.nifi.serialization.RecordSetWriter; +import org.apache.nifi.serialization.RecordSetWriterFactory; +import org.apache.nifi.serialization.SchemaRegistryService; +import org.apache.nifi.serialization.SimpleRecordSchema; +import org.apache.nifi.serialization.record.RecordSchema; +import org.apache.nifi.serialization.record.SchemaIdentifier; +import org.apache.nifi.services.protobuf.schema.ProtoSchemaParser; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; + +import static org.apache.nifi.expression.ExpressionLanguageScope.FLOWFILE_ATTRIBUTES; +import static org.apache.nifi.schema.access.SchemaAccessUtils.SCHEMA_ACCESS_STRATEGY; +import static org.apache.nifi.schema.access.SchemaAccessUtils.SCHEMA_BRANCH_NAME; +import static org.apache.nifi.schema.access.SchemaAccessUtils.SCHEMA_NAME; +import static org.apache.nifi.schema.access.SchemaAccessUtils.SCHEMA_NAME_PROPERTY; +import static org.apache.nifi.schema.access.SchemaAccessUtils.SCHEMA_REFERENCE_READER; +import static org.apache.nifi.schema.access.SchemaAccessUtils.SCHEMA_REGISTRY; +import static org.apache.nifi.schema.access.SchemaAccessUtils.SCHEMA_TEXT; +import static org.apache.nifi.schema.access.SchemaAccessUtils.SCHEMA_TEXT_PROPERTY; +import static org.apache.nifi.schema.access.SchemaAccessUtils.SCHEMA_VERSION; +import static org.apache.nifi.services.protobuf.StandardProtobufWriter.MessageNameResolverStrategy.MESSAGE_NAME_PROPERTY; + +@Tags({"protobuf", "record", "writer", "serializer", "confluent"}) +@CapabilityDescription(""" + Serializes NiFi Records into Protocol Buffers binary format. \ + Supports inline schema text and schema registry lookup for determining the Proto schema. \ + When a Schema Reference Writer is configured, a Confluent wire-format header is written; when a \ + Message Index Writer is also configured, the Confluent message index array is written after the header. \ + The target Proto message name can be determined statically using the 'Message Name' property, \ + or dynamically using a Message Name Resolver service. + A single record is written per FlowFile, since concatenated Protocol Buffers messages cannot be delimited. \ + The 'google.protobuf.Any' well-known type is not expanded on write; a Record derived from an Any-typed message \ + is serialized as an ordinary nested message rather than being re-wrapped as an Any.""") +public class StandardProtobufWriter extends SchemaRegistryService implements RecordSetWriterFactory { + + public static final PropertyDescriptor MESSAGE_NAME_RESOLUTION_STRATEGY = new PropertyDescriptor.Builder() + .name("Message Name Resolution Strategy") + .description("Strategy for determining the Protocol Buffers message name for serialization") + .required(true) + .allowableValues(MESSAGE_NAME_PROPERTY, MessageNameResolverStrategy.MESSAGE_NAME_RESOLVER) + .defaultValue(MESSAGE_NAME_PROPERTY) + .build(); + + public static final PropertyDescriptor MESSAGE_NAME = new PropertyDescriptor.Builder() + .name("Message Name") + .description("Fully qualified name of the Protocol Buffers message including its package (eg. mypackage.MyMessage).") + .required(true) + .expressionLanguageSupported(FLOWFILE_ATTRIBUTES) + .dependsOn(MESSAGE_NAME_RESOLUTION_STRATEGY, MESSAGE_NAME_PROPERTY) + .addValidator(StandardValidators.NON_EMPTY_VALIDATOR) + .build(); + + public static final PropertyDescriptor MESSAGE_NAME_RESOLVER = new PropertyDescriptor.Builder() + .name("Message Name Resolver") + .description("Service that dynamically resolves Protocol Buffer message names from FlowFile attributes. " + + "On the write side the resolver is invoked with an empty content stream, so only resolvers that derive the " + + "message name from attributes are supported; resolvers that read the message name from message content " + + "(such as the Confluent wire-format resolver used on the read side) are not applicable here.") + .required(true) + .identifiesControllerService(MessageNameResolver.class) + .dependsOn(MESSAGE_NAME_RESOLUTION_STRATEGY, MessageNameResolverStrategy.MESSAGE_NAME_RESOLVER) + .build(); + + public static final PropertyDescriptor SCHEMA_REFERENCE_WRITER = new PropertyDescriptor.Builder() + .name("Schema Reference Writer") + .description("Service used to write schema reference information, such as a Confluent wire-format header, before the serialized Protobuf content. " + + "When not configured, plain Protobuf content is written without any header.") + .required(false) + .identifiesControllerService(SchemaReferenceWriter.class) + .build(); + + public static final PropertyDescriptor MESSAGE_INDEX_WRITER = new PropertyDescriptor.Builder() + .name("Message Index Writer") + .description("Service used to write the Confluent message index array identifying the target message within the schema, written after the Schema Reference Writer header. " + + "Applicable only when producing Confluent wire-format content.") + .required(false) + .identifiesControllerService(MessageIndexWriter.class) + .build(); + + private static final PropertyDescriptor PROTOBUF_SCHEMA_TEXT = new PropertyDescriptor.Builder() + .fromPropertyDescriptor(SCHEMA_TEXT) + .required(true) + .clearValidators() + .addValidator(StandardValidators.NON_EMPTY_VALIDATOR) + .defaultValue("${proto.schema}") + .description("The text of a Proto 3 formatted Schema") + .build(); + + private static final String PROTO_EXTENSION = ".proto"; + + private static final InputStream EMPTY_INPUT_STREAM = new ByteArrayInputStream(new byte[0]); + + private volatile ProtobufSchemaCompiler schemaCompiler; + private volatile MessageNameResolver messageNameResolver; + private volatile SchemaReferenceWriter schemaReferenceWriter; + private volatile MessageIndexWriter messageIndexWriter; + private volatile SchemaRegistry schemaRegistry; + private volatile String schemaAccessStrategyValue; + private volatile PropertyValue schemaText; + private volatile PropertyValue schemaName; + private volatile PropertyValue schemaBranchName; + private volatile PropertyValue schemaVersion; + + @OnEnabled + public void onEnabled(final ConfigurationContext context) { + super.storeSchemaAccessStrategy(context); + setupMessageNameResolver(context); + schemaAccessStrategyValue = context.getProperty(SCHEMA_ACCESS_STRATEGY).getValue(); + schemaRegistry = context.getProperty(SCHEMA_REGISTRY).asControllerService(SchemaRegistry.class); + schemaReferenceWriter = context.getProperty(SCHEMA_REFERENCE_WRITER).asControllerService(SchemaReferenceWriter.class); + messageIndexWriter = context.getProperty(MESSAGE_INDEX_WRITER).asControllerService(MessageIndexWriter.class); + schemaName = context.getProperty(SCHEMA_NAME); + schemaText = context.getProperty(SCHEMA_TEXT); + schemaBranchName = context.getProperty(SCHEMA_BRANCH_NAME); + schemaVersion = context.getProperty(SCHEMA_VERSION); + } + + @Override + protected void init(final ControllerServiceInitializationContext config) throws InitializationException { + super.init(config); + schemaCompiler = new ProtobufSchemaCompiler(getIdentifier(), getLogger()); + } + + @Override + public RecordSchema getSchema(final Map variables, final RecordSchema readSchema) throws SchemaNotFoundException, IOException { + return createWriteContext(variables).recordSchema(); + } + + @Override + public RecordSetWriter createWriter(final ComponentLog logger, final RecordSchema schema, final OutputStream out, final Map variables) throws SchemaNotFoundException, IOException { + final ProtobufWriteContext context = createWriteContext(variables); + return new WriteProtobufResultWithExternalSchema(context.schema(), context.messageName(), context.recordSchema(), + context.schemaDefinition(), schemaReferenceWriter, messageIndexWriter, variables, out); + } + + @Override + protected List getSupportedPropertyDescriptors() { + final List properties = new ArrayList<>(super.getSupportedPropertyDescriptors()); + // The Schema Reference Reader is a read-side concern: a writer determines its schema from the configured + // access strategy and writes references through the Schema Reference Writer instead. + properties.removeIf(property -> SCHEMA_REFERENCE_READER.getName().equals(property.getName())); + properties.add(MESSAGE_NAME_RESOLUTION_STRATEGY); + properties.add(MESSAGE_NAME_RESOLVER); + properties.add(MESSAGE_NAME); + properties.add(SCHEMA_REFERENCE_WRITER); + properties.add(MESSAGE_INDEX_WRITER); + return properties; + } + + @Override + protected List getSchemaAccessStrategyValues() { + // Only the strategies that createSchemaDefinition supports are offered; the inherited list also contains + // the Schema Reference Reader strategy, which cannot be used to obtain a schema for writing. + return List.of(SCHEMA_NAME_PROPERTY, SCHEMA_TEXT_PROPERTY); + } + + @Override + protected PropertyDescriptor buildSchemaTextProperty() { + return PROTOBUF_SCHEMA_TEXT; + } + + private ProtobufWriteContext createWriteContext(final Map variables) throws SchemaNotFoundException, IOException { + final SchemaDefinition schemaDefinition = createSchemaDefinition(variables); + final Schema schema = schemaCompiler.compileOrGetFromCache(schemaDefinition); + final MessageName messageName = messageNameResolver.getMessageName(variables, schemaDefinition, EMPTY_INPUT_STREAM); + + final ProtoSchemaParser schemaParser = new ProtoSchemaParser(schema); + final RecordSchema parsedSchema = schemaParser.createSchema(messageName.getFullyQualifiedName()); + // Preserve the schema identifier from the SchemaDefinition so the configured Schema Reference Writer can + // write the correct schema id in the Confluent header. + final RecordSchema recordSchema = new SimpleRecordSchema(parsedSchema.getFields(), schemaDefinition.getIdentifier()); + + return new ProtobufWriteContext(schemaDefinition, schema, messageName, recordSchema); + } + + private SchemaDefinition createSchemaDefinition(final Map variables) throws SchemaNotFoundException, IOException { + if (SCHEMA_TEXT_PROPERTY.getValue().equals(schemaAccessStrategyValue)) { + return createSchemaDefinitionFromText(variables); + } else if (SCHEMA_NAME_PROPERTY.getValue().equals(schemaAccessStrategyValue)) { + return createSchemaDefinitionFromRegistry(variables); + } + + throw new SchemaNotFoundException("Unsupported schema access strategy: " + schemaAccessStrategyValue); + } + + private void setupMessageNameResolver(final ConfigurationContext context) { + final MessageNameResolverStrategy messageNameResolverStrategy = context.getProperty(MESSAGE_NAME_RESOLUTION_STRATEGY).asAllowableValue(MessageNameResolverStrategy.class); + messageNameResolver = switch (messageNameResolverStrategy) { + case MESSAGE_NAME_PROPERTY -> new PropertyMessageNameResolver(context); + case MESSAGE_NAME_RESOLVER -> context.getProperty(MESSAGE_NAME_RESOLVER).asControllerService(MessageNameResolver.class); + }; + } + + private SchemaDefinition createSchemaDefinitionFromText(final Map variables) throws SchemaNotFoundException { + final String schemaTextString = schemaText.evaluateAttributeExpressions(variables).getValue(); + validateSchemaText(schemaTextString); + + final String hash = sha256Hex(schemaTextString); + final SchemaIdentifier schemaIdentifier = SchemaIdentifier.builder() + .name(hash + PROTO_EXTENSION) + .build(); + + return new StandardSchemaDefinition(schemaIdentifier, schemaTextString, SchemaDefinition.SchemaType.PROTOBUF); + } + + private SchemaDefinition createSchemaDefinitionFromRegistry(final Map variables) throws SchemaNotFoundException, IOException { + final String schemaNameValue = schemaName.evaluateAttributeExpressions(variables).getValue(); + validateSchemaName(schemaNameValue); + + final String schemaBranchNameValue = schemaBranchName.evaluateAttributeExpressions(variables).getValue(); + final String schemaVersionValue = schemaVersion.evaluateAttributeExpressions(variables).getValue(); + + final SchemaIdentifier schemaIdentifier = buildSchemaIdentifier(schemaNameValue, schemaBranchNameValue, schemaVersionValue); + return schemaRegistry.retrieveSchemaDefinition(schemaIdentifier); + } + + private SchemaIdentifier buildSchemaIdentifier(final String schemaNameValue, final String schemaBranchNameValue, final String schemaVersionValue) throws SchemaNotFoundException { + final SchemaIdentifier.Builder identifierBuilder = SchemaIdentifier.builder().name(schemaNameValue); + + if (schemaBranchNameValue != null && !schemaBranchNameValue.isBlank()) { + identifierBuilder.branch(schemaBranchNameValue); + } + + if (schemaVersionValue != null && !schemaVersionValue.isBlank()) { + try { + identifierBuilder.version(Integer.valueOf(schemaVersionValue)); + } catch (final NumberFormatException nfe) { + throw new SchemaNotFoundException("Could not retrieve schema with name '%s' because a non-numeric version was supplied '%s'" + .formatted(schemaNameValue, schemaVersionValue), nfe); + } + } + + return identifierBuilder.build(); + } + + private String sha256Hex(final String input) { + final MessageDigest digest; + try { + digest = MessageDigest.getInstance("SHA-256"); + } catch (final NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + final byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash); + } + + private void validateSchemaText(final String schemaTextString) throws SchemaNotFoundException { + if (schemaTextString == null || schemaTextString.isBlank()) { + throw new SchemaNotFoundException("Schema text not found"); + } + } + + private void validateSchemaName(final String schemaNameValue) throws SchemaNotFoundException { + if (schemaNameValue == null || schemaNameValue.isBlank()) { + throw new SchemaNotFoundException("Schema name not provided or is blank"); + } + } + + enum MessageNameResolverStrategy implements DescribedValue { + + MESSAGE_NAME_PROPERTY("Message Name Property", "Use the 'Message Name' property value to determine the message name"), + MESSAGE_NAME_RESOLVER("Message Name Resolver", "Use a 'Message Name Resolver' service to dynamically determine the message name"); + + private final String displayName; + private final String description; + + MessageNameResolverStrategy(final String displayName, final String description) { + this.displayName = displayName; + this.description = description; + } + + @Override + public String getValue() { + return name(); + } + + @Override + public String getDisplayName() { + return displayName; + } + + @Override + public String getDescription() { + return description; + } + } + + static class PropertyMessageNameResolver extends AbstractControllerService implements MessageNameResolver { + private final PropertyContext context; + + PropertyMessageNameResolver(final PropertyContext context) { + this.context = context; + } + + @Override + public MessageName getMessageName(final Map variables, final SchemaDefinition schemaDefinition, final InputStream in) { + final String messageName = context.getProperty(MESSAGE_NAME).evaluateAttributeExpressions(variables).getValue(); + return StandardMessageNameFactory.fromName(messageName); + } + } + + private record ProtobufWriteContext(SchemaDefinition schemaDefinition, Schema schema, MessageName messageName, RecordSchema recordSchema) { + } +} diff --git a/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/main/java/org/apache/nifi/services/protobuf/WriteProtobufResultWithExternalSchema.java b/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/main/java/org/apache/nifi/services/protobuf/WriteProtobufResultWithExternalSchema.java new file mode 100644 index 000000000000..40a35fb061dd --- /dev/null +++ b/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/main/java/org/apache/nifi/services/protobuf/WriteProtobufResultWithExternalSchema.java @@ -0,0 +1,137 @@ +/* + * 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.Schema; +import org.apache.nifi.schemaregistry.services.MessageIndexWriter; +import org.apache.nifi.schemaregistry.services.MessageName; +import org.apache.nifi.schemaregistry.services.SchemaDefinition; +import org.apache.nifi.schemaregistry.services.SchemaReferenceWriter; +import org.apache.nifi.serialization.AbstractRecordSetWriter; +import org.apache.nifi.serialization.record.Record; +import org.apache.nifi.serialization.record.RecordSchema; +import org.apache.nifi.services.protobuf.converter.ProtobufDataSerializer; + +import java.io.BufferedOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.Map; + +/** + * Writes Records as Protocol Buffers binary content. When a {@link SchemaReferenceWriter} is + * configured, a Confluent wire-format header (magic byte and schema identifier) is written first; + * when a {@link MessageIndexWriter} is configured, the Confluent message index array follows the + * header. The serialized Protobuf payload is written last. + *

+ * The Confluent framing (header and message index) is written once at the beginning of the record + * set, mirroring {@code WriteAvroResultWithExternalSchema}; the typical Confluent use case writes a + * single message per FlowFile. + */ +public class WriteProtobufResultWithExternalSchema extends AbstractRecordSetWriter { + + private final RecordSchema recordSchema; + private final SchemaDefinition schemaDefinition; + private final MessageName messageName; + private final SchemaReferenceWriter schemaReferenceWriter; + private final MessageIndexWriter messageIndexWriter; + private final Map variables; + private final ProtobufDataSerializer serializer; + private final OutputStream buffered; + private boolean closed = false; + + public WriteProtobufResultWithExternalSchema(final Schema schema, + final MessageName messageName, + final RecordSchema recordSchema, + final SchemaDefinition schemaDefinition, + final SchemaReferenceWriter schemaReferenceWriter, + final MessageIndexWriter messageIndexWriter, + final Map variables, + final OutputStream out) { + super(out); + this.recordSchema = recordSchema; + this.schemaDefinition = schemaDefinition; + this.messageName = messageName; + this.schemaReferenceWriter = schemaReferenceWriter; + this.messageIndexWriter = messageIndexWriter; + this.variables = variables; + this.buffered = new BufferedOutputStream(out); + this.serializer = new ProtobufDataSerializer(schema, messageName.getFullyQualifiedName()); + } + + @Override + protected void onBeginRecordSet() throws IOException { + writeConfluentFraming(buffered); + } + + @Override + protected Map onFinishRecordSet() throws IOException { + flush(); + return Map.of(); + } + + @Override + public Map writeRecord(final Record record) throws IOException { + // Concatenated top-level Protobuf messages cannot be delimited: a standard decoder would merge them into a + // single message (repeated fields accumulate, singular fields take last-wins). Only a single record per + // FlowFile can be represented, which also matches the Confluent one-message-per-record convention. + if (getRecordCount() > 0) { + throw new IOException("Protobuf output supports only a single record because concatenated Protobuf messages cannot be delimited"); + } + + // If we are not writing an active record set, then we need to ensure that the Confluent framing is written. + if (!isActiveRecordSet()) { + flush(); + writeConfluentFraming(buffered); + } + + final byte[] payload = serializer.serialize(record); + buffered.write(payload); + return Map.of(); + } + + private void writeConfluentFraming(final OutputStream out) throws IOException { + if (schemaReferenceWriter != null) { + schemaReferenceWriter.writeHeader(recordSchema, out); + } + if (messageIndexWriter != null) { + messageIndexWriter.writeMessageIndex(variables, schemaDefinition, messageName, out); + } + } + + @Override + public void flush() throws IOException { + buffered.flush(); + } + + @Override + public String getMimeType() { + return "application/octet-stream"; + } + + @Override + public void close() throws IOException { + if (closed) { + return; + } + closed = true; + + // Ensure buffered content is flushed to the underlying stream before it is closed, including the + // write-without-active-record-set path where onFinishRecordSet is never invoked. + flush(); + super.close(); + } +} diff --git a/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/main/java/org/apache/nifi/services/protobuf/converter/ProtobufDataSerializer.java b/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/main/java/org/apache/nifi/services/protobuf/converter/ProtobufDataSerializer.java new file mode 100644 index 000000000000..70f198ded1b6 --- /dev/null +++ b/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/main/java/org/apache/nifi/services/protobuf/converter/ProtobufDataSerializer.java @@ -0,0 +1,300 @@ +/* + * 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.converter; + +import com.google.protobuf.CodedOutputStream; +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.ProtoType; +import com.squareup.wire.schema.Schema; +import org.apache.nifi.serialization.record.Record; +import org.apache.nifi.serialization.record.util.DataTypeUtils; +import org.apache.nifi.services.protobuf.FieldType; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Serializes a NiFi {@link Record} into Protocol Buffers binary payload using a Square Wire + * {@link Schema}. This is the write-side inverse of {@code ProtobufDataConverter}: it walks the + * declared fields of the target message type and encodes each present Record value to a + * {@link CodedOutputStream} following the Protocol Buffers wire format. + *

+ * This class has no dependency on the NiFi framework and can be exercised directly with plain + * unit tests. + */ +public class ProtobufDataSerializer { + + private static final int WIRETYPE_VARINT = 0; + private static final int WIRETYPE_FIXED64 = 1; + private static final int WIRETYPE_LENGTH_DELIMITED = 2; + private static final int WIRETYPE_FIXED32 = 5; + + private static final int MAP_KEY_TAG = 1; + private static final int MAP_VALUE_TAG = 2; + + private final Schema schema; + private final String rootMessageType; + + public ProtobufDataSerializer(final Schema schema, final String rootMessageType) { + this.schema = schema; + this.rootMessageType = rootMessageType; + } + + /** + * Serializes the provided Record into Protocol Buffers binary format for the configured root + * message type. + * + * @param record the record to serialize + * @return the serialized protobuf payload + * @throws IOException if the record cannot be encoded + */ + public byte[] serialize(final Record record) throws IOException { + final MessageType messageType = (MessageType) schema.getType(rootMessageType); + Objects.requireNonNull(messageType, String.format("Message with name [%s] not found in the provided proto files", rootMessageType)); + + return serializeMessage(messageType, record); + } + + private byte[] serializeMessage(final MessageType messageType, final Record record) throws IOException { + final ByteArrayOutputStream output = new ByteArrayOutputStream(); + final CodedOutputStream codedOutput = CodedOutputStream.newInstance(output); + + for (final Field field : messageType.getDeclaredFields()) { + writeField(codedOutput, field, record.getValue(field.getName())); + } + for (final Field field : messageType.getExtensionFields()) { + writeField(codedOutput, field, record.getValue(field.getName())); + } + for (final OneOf oneOf : messageType.getOneOfs()) { + for (final Field field : oneOf.getFields()) { + writeField(codedOutput, field, record.getValue(field.getName())); + } + } + + codedOutput.flush(); + return output.toByteArray(); + } + + private void writeField(final CodedOutputStream output, final Field field, final Object value) throws IOException { + if (value == null) { + return; + } + + final int tag = field.getTag(); + final ProtoType protoType = field.getType(); + + if (protoType.isMap()) { + writeMap(output, tag, protoType, value); + } else if (field.isRepeated()) { + writeRepeated(output, tag, protoType, value); + } else { + writeSingleValue(output, tag, protoType, value); + } + } + + private void writeRepeated(final CodedOutputStream output, final int tag, final ProtoType protoType, final Object value) throws IOException { + final Object[] values = toArray(value); + + if (isPackable(protoType)) { + // proto3 packs repeated scalar and enum fields into a single length-delimited entry + final ByteArrayOutputStream packedBytes = new ByteArrayOutputStream(); + final CodedOutputStream packedOutput = CodedOutputStream.newInstance(packedBytes); + for (final Object element : values) { + writeScalarOrEnumValueNoTag(packedOutput, protoType, element); + } + packedOutput.flush(); + + output.writeTag(tag, WIRETYPE_LENGTH_DELIMITED); + final byte[] packed = packedBytes.toByteArray(); + output.writeUInt32NoTag(packed.length); + output.writeRawBytes(packed); + } else { + // repeated messages, strings and bytes are written as separate length-delimited entries + for (final Object element : values) { + writeSingleValue(output, tag, protoType, element); + } + } + } + + private void writeSingleValue(final CodedOutputStream output, final int tag, final ProtoType protoType, final Object value) throws IOException { + if (protoType.isScalar()) { + final FieldType fieldType = FieldType.findValue(protoType.getSimpleName()); + output.writeTag(tag, wireTypeFor(fieldType)); + writeScalarValueNoTag(output, fieldType, value); + return; + } + + if (schema.getType(protoType) instanceof EnumType) { + output.writeTag(tag, WIRETYPE_VARINT); + output.writeEnumNoTag(enumTag(protoType, value)); + return; + } + + // nested message + final MessageType messageType = (MessageType) schema.getType(protoType); + Objects.requireNonNull(messageType, String.format("Message type with name [%s] not found in the provided proto files", protoType)); + final byte[] nested = serializeMessage(messageType, toRecord(value, protoType)); + output.writeTag(tag, WIRETYPE_LENGTH_DELIMITED); + output.writeUInt32NoTag(nested.length); + output.writeRawBytes(nested); + } + + private void writeMap(final CodedOutputStream output, final int tag, final ProtoType protoType, final Object value) throws IOException { + if (!(value instanceof final Map map)) { + throw new IOException(String.format("Expected a Map value for map field but received [%s]", value.getClass())); + } + + final ProtoType keyType = protoType.getKeyType(); + final ProtoType valueType = protoType.getValueType(); + + for (final Map.Entry entry : map.entrySet()) { + final ByteArrayOutputStream entryBytes = new ByteArrayOutputStream(); + final CodedOutputStream entryOutput = CodedOutputStream.newInstance(entryBytes); + + writeSingleValue(entryOutput, MAP_KEY_TAG, keyType, entry.getKey()); + if (entry.getValue() != null) { + writeSingleValue(entryOutput, MAP_VALUE_TAG, valueType, entry.getValue()); + } + entryOutput.flush(); + + output.writeTag(tag, WIRETYPE_LENGTH_DELIMITED); + final byte[] entryEncoded = entryBytes.toByteArray(); + output.writeUInt32NoTag(entryEncoded.length); + output.writeRawBytes(entryEncoded); + } + } + + private void writeScalarOrEnumValueNoTag(final CodedOutputStream output, final ProtoType protoType, final Object value) throws IOException { + if (protoType.isScalar()) { + writeScalarValueNoTag(output, FieldType.findValue(protoType.getSimpleName()), value); + } else { + output.writeEnumNoTag(enumTag(protoType, value)); + } + } + + private void writeScalarValueNoTag(final CodedOutputStream output, final FieldType fieldType, final Object value) throws IOException { + // 32-bit varint types (uint32, sint32, fixed32) map to a Record LONG in ProtoSchemaParser, so they are + // coerced through long and narrowed to int; the unsigned/zigzag encoding is handled by CodedOutputStream. + switch (fieldType) { + case BOOL -> output.writeBoolNoTag(DataTypeUtils.toBoolean(value, null)); + case INT32 -> output.writeInt32NoTag((int) DataTypeUtils.toLong(value, null).longValue()); + case SFIXED32 -> output.writeSFixed32NoTag((int) DataTypeUtils.toLong(value, null).longValue()); + case UINT32 -> output.writeUInt32NoTag((int) DataTypeUtils.toLong(value, null).longValue()); + case SINT32 -> output.writeSInt32NoTag((int) DataTypeUtils.toLong(value, null).longValue()); + case FIXED32 -> output.writeFixed32NoTag((int) DataTypeUtils.toLong(value, null).longValue()); + case INT64 -> output.writeInt64NoTag(DataTypeUtils.toLong(value, null)); + case SFIXED64 -> output.writeSFixed64NoTag(DataTypeUtils.toLong(value, null)); + case SINT64 -> output.writeSInt64NoTag(DataTypeUtils.toLong(value, null)); + case UINT64 -> output.writeUInt64NoTag(toBigInteger(value).longValue()); + case FIXED64 -> output.writeFixed64NoTag(toBigInteger(value).longValue()); + case FLOAT -> output.writeFloatNoTag(DataTypeUtils.toFloat(value, null)); + case DOUBLE -> output.writeDoubleNoTag(DataTypeUtils.toDouble(value, null)); + case STRING -> output.writeStringNoTag(DataTypeUtils.toString(value, (String) null)); + case BYTES -> output.writeByteArrayNoTag(toByteArray(value)); + } + } + + private int wireTypeFor(final FieldType fieldType) { + return switch (fieldType) { + case BOOL, INT32, INT64, UINT32, UINT64, SINT32, SINT64 -> WIRETYPE_VARINT; + case FIXED32, SFIXED32, FLOAT -> WIRETYPE_FIXED32; + case FIXED64, SFIXED64, DOUBLE -> WIRETYPE_FIXED64; + case STRING, BYTES -> WIRETYPE_LENGTH_DELIMITED; + }; + } + + private boolean isPackable(final ProtoType protoType) { + if (protoType.isScalar()) { + final FieldType fieldType = FieldType.findValue(protoType.getSimpleName()); + return fieldType != FieldType.STRING && fieldType != FieldType.BYTES; + } + // enums are packable; messages are not + return schema.getType(protoType) instanceof EnumType; + } + + private int enumTag(final ProtoType protoType, final Object value) { + final EnumType enumType = (EnumType) schema.getType(protoType); + Objects.requireNonNull(enumType, String.format("Enum with name [%s] not found in the provided proto files", protoType)); + + final String constantName = String.valueOf(value); + final EnumConstant constant = enumType.constant(constantName); + if (constant == null) { + throw new IllegalStateException(String.format("Enum constant [%s] not found in enum [%s]", constantName, protoType)); + } + return constant.getTag(); + } + + private Record toRecord(final Object value, final ProtoType protoType) throws IOException { + if (value instanceof final Record record) { + return record; + } + throw new IOException(String.format("Expected a Record value for message field [%s] but received [%s]", protoType, value.getClass())); + } + + private Object[] toArray(final Object value) { + if (value instanceof final Object[] array) { + return array; + } + if (value instanceof final List list) { + return list.toArray(); + } + return new Object[] {value}; + } + + private BigInteger toBigInteger(final Object value) { + if (value instanceof final BigInteger bigInteger) { + return bigInteger; + } + if (value instanceof final Number number) { + return BigInteger.valueOf(number.longValue()); + } + return new BigInteger(String.valueOf(value)); + } + + private byte[] toByteArray(final Object value) { + if (value instanceof final byte[] bytes) { + return bytes; + } + if (value instanceof final Object[] array) { + final byte[] bytes = new byte[array.length]; + for (int i = 0; i < array.length; i++) { + bytes[i] = ((Number) array[i]).byteValue(); + } + return bytes; + } + final List collected = new ArrayList<>(); + if (value instanceof final List list) { + for (final Object element : list) { + collected.add(((Number) element).byteValue()); + } + } + final byte[] bytes = new byte[collected.size()]; + for (int i = 0; i < collected.size(); i++) { + bytes[i] = collected.get(i); + } + return bytes; + } +} diff --git a/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/main/resources/META-INF/services/org.apache.nifi.controller.ControllerService b/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/main/resources/META-INF/services/org.apache.nifi.controller.ControllerService index 8ff7783b668d..4755655bd2a1 100644 --- a/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/main/resources/META-INF/services/org.apache.nifi.controller.ControllerService +++ b/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/main/resources/META-INF/services/org.apache.nifi.controller.ControllerService @@ -14,4 +14,5 @@ # limitations under the License. org.apache.nifi.services.protobuf.ProtobufReader -org.apache.nifi.services.protobuf.StandardProtobufReader \ No newline at end of file +org.apache.nifi.services.protobuf.StandardProtobufReader +org.apache.nifi.services.protobuf.StandardProtobufWriter 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 000000000000..1fd1b51b2121 --- /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,117 @@ +/* + * 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.Schema; +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 java.util.Map; + +import static org.apache.nifi.schemaregistry.services.SchemaDefinition.SchemaType.PROTOBUF; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Verifies schema compilation when a schema imports another file. Confluent Schema Registry keys each + * reference by the import path used inside the {@code .proto} (for example {@code airlines/ph/cdm/shared.proto}), + * while the referenced schema's identifier carries the registry subject, which is a different value and is not + * required to look like a file path. + */ +class TestProtobufSchemaCompiler { + + private static final String ROOT_SUBJECT = "airlines.ph.cdm.reservation.AirlineReservation"; + private static final String IMPORT_PATH = "airlines/ph/cdm/shared.proto"; + private static final String REFERENCE_SUBJECT = "airlines.ph.cdm.shared"; + + private static final String ROOT_SCHEMA = """ + syntax = "proto3"; + package airlines.ph.cdm.reservation; + import "airlines/ph/cdm/shared.proto"; + message AirlineReservation { + string reservation_id = 1; + airlines.ph.cdm.Status status = 2; + }"""; + + private static final String REFERENCED_SCHEMA = """ + syntax = "proto3"; + package airlines.ph.cdm; + message Status { + string code = 1; + }"""; + + @Test + void testCompileSchemaWithCrossFileImportFromRegistry() { + final SchemaDefinition referencedSchema = new StandardSchemaDefinition( + SchemaIdentifier.builder().name(REFERENCE_SUBJECT).id(4L).version(1).build(), + REFERENCED_SCHEMA, + PROTOBUF); + + final SchemaDefinition rootSchema = new StandardSchemaDefinition( + SchemaIdentifier.builder().name(ROOT_SUBJECT).id(3L).version(2).build(), + ROOT_SCHEMA, + PROTOBUF, + Map.of(IMPORT_PATH, referencedSchema)); + + final ProtobufSchemaCompiler compiler = new ProtobufSchemaCompiler("test", new MockComponentLog("test", new Object())); + final Schema compiled = compiler.compileOrGetFromCache(rootSchema); + + assertNotNull(compiled.getType("airlines.ph.cdm.reservation.AirlineReservation")); + assertNotNull(compiled.getType("airlines.ph.cdm.Status")); + } + + @Test + void testCompileSchemaWithNestedCrossFileImports() { + final SchemaDefinition leafSchema = new StandardSchemaDefinition( + SchemaIdentifier.builder().name("airlines.ph.cdm.common").id(5L).version(1).build(), + """ + syntax = "proto3"; + package airlines.ph.cdm.common; + message Audit { + string created_by = 1; + }""", + PROTOBUF); + + final SchemaDefinition referencedSchema = new StandardSchemaDefinition( + SchemaIdentifier.builder().name(REFERENCE_SUBJECT).id(4L).version(1).build(), + """ + syntax = "proto3"; + package airlines.ph.cdm; + import "airlines/ph/cdm/common/audit.proto"; + message Status { + string code = 1; + airlines.ph.cdm.common.Audit audit = 2; + }""", + PROTOBUF, + Map.of("airlines/ph/cdm/common/audit.proto", leafSchema)); + + final SchemaDefinition rootSchema = new StandardSchemaDefinition( + SchemaIdentifier.builder().name(ROOT_SUBJECT).id(3L).version(2).build(), + ROOT_SCHEMA, + PROTOBUF, + Map.of(IMPORT_PATH, referencedSchema)); + + final ProtobufSchemaCompiler compiler = new ProtobufSchemaCompiler("test", new MockComponentLog("test", new Object())); + final Schema compiled = compiler.compileOrGetFromCache(rootSchema); + + assertNotNull(compiled.getType("airlines.ph.cdm.reservation.AirlineReservation")); + assertNotNull(compiled.getType("airlines.ph.cdm.Status")); + assertNotNull(compiled.getType("airlines.ph.cdm.common.Audit")); + } +} 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 54b36a267b45..c99a40f04f3c 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 @@ -44,7 +44,7 @@ import static org.apache.nifi.schema.access.SchemaAccessUtils.SCHEMA_TEXT; import static org.apache.nifi.schema.access.SchemaAccessUtils.SCHEMA_TEXT_PROPERTY; import static org.apache.nifi.services.protobuf.ProtoTestUtil.generateInputDataForProto3; -import static org.apache.nifi.services.protobuf.ProtobufSchemaValidator.validateSchemaDefinitionIdentifiers; +import static org.apache.nifi.services.protobuf.ProtobufSchemaValidator.validateSchemaReferencePaths; import static org.apache.nifi.services.protobuf.StandardProtobufReader.MESSAGE_NAME; import static org.apache.nifi.services.protobuf.StandardProtobufReader.MESSAGE_NAME_RESOLUTION_STRATEGY; import static org.apache.nifi.services.protobuf.StandardProtobufReader.MESSAGE_NAME_RESOLVER; @@ -137,7 +137,7 @@ void testValidSchemaDefinitionWithProtoExtension() { enableAllControllerServices(); - validateSchemaDefinitionIdentifiers(validMainSchema, true); + validateSchemaReferencePaths(validMainSchema); } @Test @@ -147,29 +147,42 @@ void testRootSchemaIsAllowedToHaveInvalidName() { enableAllControllerServices(); - validateSchemaDefinitionIdentifiers(validSchema, true); + validateSchemaReferencePaths(validSchema); } @Test - void testValidMainSchemaWithInvalidReferencedSchema() { - final SchemaDefinition invalidReferencedSchema = createSchemaDefinition("user_profile.invalid"); + void testReferenceKeyedByPathWithoutProtoExtensionIsRejected() { + // The key is the import path, and the compiler only discovers files named *.proto. + final SchemaDefinition invalidReferencedSchema = createSchemaDefinition("user_profile.proto"); final SchemaDefinition mixedSchema = createSchemaDefinition("user_settings.proto", Map.of("user_profile.invalid", invalidReferencedSchema)); enableAllControllerServices(); - final IllegalArgumentException referencedException = assertThrows(IllegalArgumentException.class, () -> { - validateSchemaDefinitionIdentifiers(mixedSchema, true); - }); + final IllegalArgumentException referencedException = assertThrows(IllegalArgumentException.class, + () -> validateSchemaReferencePaths(mixedSchema)); - assertTrue(referencedException.getMessage().contains("ends with .proto extension")); + assertTrue(referencedException.getMessage().contains(".proto extension")); + } + + @Test + void testReferencedSubjectWithoutProtoExtensionIsAccepted() { + // A registry subject is unrelated to the import path and legitimately has no .proto suffix; under the + // Confluent RecordNameStrategy it is a fully qualified record name. Only the key has to look like a path. + final SchemaDefinition referencedSchema = createSchemaDefinition("airlines.ph.cdm.shared"); + final SchemaDefinition mainSchema = createSchemaDefinition("airlines.ph.cdm.reservation.AirlineReservation", + Map.of("airlines/ph/cdm/shared.proto", referencedSchema)); + + enableAllControllerServices(); + + validateSchemaReferencePaths(mainSchema); } @Test void testSchemaDefinitionWithMissingName() { final SchemaDefinition schemaWithoutName = createSchemaDefinitionWithoutName(); enableAllControllerServices(); - validateSchemaDefinitionIdentifiers(schemaWithoutName, true); + validateSchemaReferencePaths(schemaWithoutName); } @Nested diff --git a/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/test/java/org/apache/nifi/services/protobuf/TestStandardProtobufWriter.java b/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/test/java/org/apache/nifi/services/protobuf/TestStandardProtobufWriter.java new file mode 100644 index 000000000000..596912bd9710 --- /dev/null +++ b/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/test/java/org/apache/nifi/services/protobuf/TestStandardProtobufWriter.java @@ -0,0 +1,317 @@ +/* + * 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.Schema; +import org.apache.nifi.components.AllowableValue; +import org.apache.nifi.components.PropertyDescriptor; +import org.apache.nifi.controller.AbstractControllerService; +import org.apache.nifi.schema.access.SchemaField; +import org.apache.nifi.schema.access.SchemaNotFoundException; +import org.apache.nifi.schemaregistry.services.MessageIndexWriter; +import org.apache.nifi.schemaregistry.services.MessageName; +import org.apache.nifi.schemaregistry.services.SchemaDefinition; +import org.apache.nifi.schemaregistry.services.SchemaReferenceWriter; +import org.apache.nifi.serialization.RecordReader; +import org.apache.nifi.serialization.RecordSetWriter; +import org.apache.nifi.serialization.record.MapRecord; +import org.apache.nifi.serialization.record.Record; +import org.apache.nifi.serialization.record.RecordSchema; +import org.apache.nifi.services.protobuf.converter.ProtobufDataConverter; +import org.apache.nifi.services.protobuf.schema.ProtoSchemaParser; +import org.apache.nifi.util.NoOpProcessor; +import org.apache.nifi.util.TestRunner; +import org.apache.nifi.util.TestRunners; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static java.util.Collections.emptyMap; +import static java.util.Collections.emptySet; +import static org.apache.nifi.schema.access.SchemaAccessUtils.SCHEMA_ACCESS_STRATEGY; +import static org.apache.nifi.schema.access.SchemaAccessUtils.SCHEMA_NAME_PROPERTY; +import static org.apache.nifi.schema.access.SchemaAccessUtils.SCHEMA_REFERENCE_READER; +import static org.apache.nifi.schema.access.SchemaAccessUtils.SCHEMA_TEXT; +import static org.apache.nifi.schema.access.SchemaAccessUtils.SCHEMA_TEXT_PROPERTY; +import static org.apache.nifi.services.protobuf.ProtoTestUtil.generateInputDataForProto3; +import static org.apache.nifi.services.protobuf.ProtoTestUtil.generateInputDataForRepeatedProto3; +import static org.apache.nifi.services.protobuf.ProtoTestUtil.loadProto3TestSchema; +import static org.apache.nifi.services.protobuf.ProtoTestUtil.loadRepeatedProto3TestSchema; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TestStandardProtobufWriter { + + private static final String PROTO_3_MESSAGE = "Proto3Message"; + private static final byte[] FAKE_HEADER = {0x00, 0x00, 0x00, 0x00, 0x2A}; + private static final byte[] FAKE_INDEX = {0x00}; + + private TestRunner runner; + private StandardProtobufWriter writer; + + @BeforeEach + void setUp() throws Exception { + runner = TestRunners.newTestRunner(NoOpProcessor.class); + writer = new StandardProtobufWriter(); + runner.addControllerService("writer", writer); + runner.setProperty(writer, SCHEMA_ACCESS_STRATEGY, SCHEMA_TEXT_PROPERTY.getValue()); + runner.setProperty(writer, SCHEMA_TEXT, getTestProto3File()); + runner.setProperty(writer, StandardProtobufWriter.MESSAGE_NAME_RESOLUTION_STRATEGY, + StandardProtobufWriter.MessageNameResolverStrategy.MESSAGE_NAME_PROPERTY.getValue()); + runner.setProperty(writer, StandardProtobufWriter.MESSAGE_NAME, PROTO_3_MESSAGE); + } + + @Test + void testWritePlainProtobufRoundTrip() throws Exception { + runner.enableControllerService(writer); + + final byte[] output = writeSingleRecord(buildProto3Record()); + + assertProto3Message(new ByteArrayInputStream(output)); + } + + @Test + void testOnlySupportedSchemaAccessStrategiesAreOffered() { + // The inherited strategy list also contains the Schema Reference Reader strategy, which the writer cannot + // use to obtain a schema; offering it would let a user configure a service that always fails at runtime. + final List descriptors = writer.getPropertyDescriptors(); + final PropertyDescriptor strategy = descriptors.stream() + .filter(descriptor -> SCHEMA_ACCESS_STRATEGY.getName().equals(descriptor.getName())) + .findFirst() + .orElseThrow(); + + final List allowed = strategy.getAllowableValues().stream().map(AllowableValue::getValue).toList(); + assertEquals(List.of(SCHEMA_NAME_PROPERTY.getValue(), SCHEMA_TEXT_PROPERTY.getValue()), allowed); + + // The read-side Schema Reference Reader property is not applicable to a writer. + assertTrue(descriptors.stream().noneMatch(descriptor -> SCHEMA_REFERENCE_READER.getName().equals(descriptor.getName()))); + } + + @Test + void testWriteWithoutActiveRecordSetFlushesOnClose() throws Exception { + runner.enableControllerService(writer); + + final RecordSchema writeSchema = writer.getSchema(emptyMap(), null); + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + // No beginRecordSet/finishRecordSet: content is written outside an active record set and must survive close(). + try (RecordSetWriter recordSetWriter = writer.createWriter(runner.getLogger(), writeSchema, out, emptyMap())) { + recordSetWriter.write(buildProto3Record()); + } + + assertProto3Message(new ByteArrayInputStream(out.toByteArray())); + } + + @Test + void testWriteMultipleRecordsFailsFast() throws Exception { + runner.enableControllerService(writer); + + final RecordSchema writeSchema = writer.getSchema(emptyMap(), null); + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (RecordSetWriter recordSetWriter = writer.createWriter(runner.getLogger(), writeSchema, out, emptyMap())) { + recordSetWriter.beginRecordSet(); + recordSetWriter.write(buildProto3Record()); + assertThrows(IOException.class, () -> recordSetWriter.write(buildProto3Record())); + } + } + + @Test + void testWriteConfluentFramingOrder() throws Exception { + final FakeSchemaReferenceWriter referenceWriter = new FakeSchemaReferenceWriter(); + final FakeMessageIndexWriter indexWriter = new FakeMessageIndexWriter(); + runner.addControllerService("referenceWriter", referenceWriter); + runner.addControllerService("indexWriter", indexWriter); + runner.enableControllerService(referenceWriter); + runner.enableControllerService(indexWriter); + runner.setProperty(writer, StandardProtobufWriter.SCHEMA_REFERENCE_WRITER, "referenceWriter"); + runner.setProperty(writer, StandardProtobufWriter.MESSAGE_INDEX_WRITER, "indexWriter"); + runner.enableControllerService(writer); + + final byte[] output = writeSingleRecord(buildProto3Record()); + + // Confluent framing: [header][message index][protobuf payload] + final byte[] header = Arrays.copyOfRange(output, 0, FAKE_HEADER.length); + final byte[] index = Arrays.copyOfRange(output, FAKE_HEADER.length, FAKE_HEADER.length + FAKE_INDEX.length); + assertArrayEquals(FAKE_HEADER, header); + assertArrayEquals(FAKE_INDEX, index); + + final byte[] payload = Arrays.copyOfRange(output, FAKE_HEADER.length + FAKE_INDEX.length, output.length); + assertProto3Message(new ByteArrayInputStream(payload)); + } + + @Test + void testRoundTripThroughStandardProtobufReaderProto3() throws Exception { + runner.enableControllerService(writer); + final byte[] output = writeSingleRecord(buildProto3Record()); + + final StandardProtobufReader reader = createAndEnableReader("proto3reader", getTestProto3File(), PROTO_3_MESSAGE); + final RecordReader recordReader = reader.createRecordReader(emptyMap(), new ByteArrayInputStream(output), output.length, runner.getLogger()); + + final Record record = recordReader.nextRecord(); + assertEquals(true, record.getValue("booleanField")); + assertEquals("Test text", record.getValue("stringField")); + assertEquals(Integer.MAX_VALUE, record.getValue("int32Field")); + assertEquals(Long.MAX_VALUE, record.getValue("int64Field")); + + final Record nested = (Record) record.getValue("nestedMessage"); + assertEquals("ENUM_VALUE_3", nested.getValue("testEnum")); + final Object[] nested2 = (Object[]) nested.getValue("nestedMessage2"); + final Record oneOfHolder = (Record) nested2[0]; + assertEquals(3, oneOfHolder.getValue("int32Option")); + + assertNull(recordReader.nextRecord()); + } + + @Test + void testRoundTripThroughStandardProtobufReaderRepeated() throws Exception { + final String repeatedSchema = getSchemaFile("test_repeated_proto3.proto"); + final StandardProtobufWriter repeatedWriter = createAndEnableWriter("repeatedwriter", repeatedSchema, "RootMessage"); + final byte[] output = writeSingleRecordWith(repeatedWriter, buildRepeatedRecord()); + + final StandardProtobufReader reader = createAndEnableReader("repeatedreader", repeatedSchema, "RootMessage"); + final RecordReader recordReader = reader.createRecordReader(emptyMap(), new ByteArrayInputStream(output), output.length, runner.getLogger()); + + final Record record = recordReader.nextRecord(); + final Object[] repeatedMessage = (Object[]) record.getValue("repeatedMessage"); + final Record first = (Record) repeatedMessage[0]; + assertArrayEquals(new Object[]{true, false}, (Object[]) first.getValue("booleanField")); + assertArrayEquals(new Object[]{"Test text1", "Test text2"}, (Object[]) first.getValue("stringField")); + assertArrayEquals(new Object[]{"ENUM_VALUE_2", "ENUM_VALUE_3"}, (Object[]) first.getValue("testEnum")); + + assertNull(recordReader.nextRecord()); + } + + private MapRecord buildRepeatedRecord() throws Exception { + final Schema schema = loadRepeatedProto3TestSchema(); + final RecordSchema recordSchema = new ProtoSchemaParser(schema).createSchema("RootMessage"); + return new ProtobufDataConverter(schema, "RootMessage", recordSchema, false, false) + .createRecord(generateInputDataForRepeatedProto3()); + } + + private StandardProtobufReader createAndEnableReader(final String id, final String schemaText, final String messageName) throws Exception { + final StandardProtobufReader reader = new StandardProtobufReader(); + runner.addControllerService(id, reader); + runner.setProperty(reader, SCHEMA_ACCESS_STRATEGY, SCHEMA_TEXT_PROPERTY.getValue()); + runner.setProperty(reader, SCHEMA_TEXT, schemaText); + runner.setProperty(reader, StandardProtobufReader.MESSAGE_NAME_RESOLUTION_STRATEGY, + StandardProtobufReader.MessageNameResolverStrategy.MESSAGE_NAME_PROPERTY.getValue()); + runner.setProperty(reader, StandardProtobufReader.MESSAGE_NAME, messageName); + runner.enableControllerService(reader); + return reader; + } + + private StandardProtobufWriter createAndEnableWriter(final String id, final String schemaText, final String messageName) throws Exception { + final StandardProtobufWriter protobufWriter = new StandardProtobufWriter(); + runner.addControllerService(id, protobufWriter); + runner.setProperty(protobufWriter, SCHEMA_ACCESS_STRATEGY, SCHEMA_TEXT_PROPERTY.getValue()); + runner.setProperty(protobufWriter, SCHEMA_TEXT, schemaText); + runner.setProperty(protobufWriter, StandardProtobufWriter.MESSAGE_NAME_RESOLUTION_STRATEGY, + StandardProtobufWriter.MessageNameResolverStrategy.MESSAGE_NAME_PROPERTY.getValue()); + runner.setProperty(protobufWriter, StandardProtobufWriter.MESSAGE_NAME, messageName); + runner.enableControllerService(protobufWriter); + return protobufWriter; + } + + private byte[] writeSingleRecordWith(final StandardProtobufWriter protobufWriter, final MapRecord record) throws IOException, SchemaNotFoundException { + final RecordSchema writeSchema = protobufWriter.getSchema(emptyMap(), null); + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (RecordSetWriter recordSetWriter = protobufWriter.createWriter(runner.getLogger(), writeSchema, out, emptyMap())) { + recordSetWriter.beginRecordSet(); + recordSetWriter.write(record); + recordSetWriter.finishRecordSet(); + recordSetWriter.flush(); + } + return out.toByteArray(); + } + + private MapRecord buildProto3Record() throws Exception { + final Schema schema = loadProto3TestSchema(); + final RecordSchema recordSchema = new ProtoSchemaParser(schema).createSchema(PROTO_3_MESSAGE); + return new ProtobufDataConverter(schema, PROTO_3_MESSAGE, recordSchema, false, false) + .createRecord(generateInputDataForProto3()); + } + + private byte[] writeSingleRecord(final MapRecord record) throws IOException, SchemaNotFoundException { + return writeSingleRecordWith(writer, record); + } + + private void assertProto3Message(final ByteArrayInputStream payload) throws IOException { + final Schema schema = loadProto3TestSchema(); + final RecordSchema recordSchema = new ProtoSchemaParser(schema).createSchema(PROTO_3_MESSAGE); + final MapRecord record = new ProtobufDataConverter(schema, PROTO_3_MESSAGE, recordSchema, false, false) + .createRecord(payload); + + assertEquals(true, record.getValue("booleanField")); + assertEquals("Test text", record.getValue("stringField")); + assertEquals(Integer.MAX_VALUE, record.getValue("int32Field")); + assertEquals(Long.MAX_VALUE, record.getValue("int64Field")); + assertArrayEquals("Test bytes".getBytes(), (byte[]) record.getValue("bytesField")); + + final MapRecord nestedRecord = (MapRecord) record.getValue("nestedMessage"); + assertEquals("ENUM_VALUE_3", nestedRecord.getValue("testEnum")); + } + + private String getTestProto3File() { + return getSchemaFile("test_proto3.proto"); + } + + private String getSchemaFile(final String resourceName) { + try { + return new String(getClass().getClassLoader().getResourceAsStream(resourceName).readAllBytes()); + } catch (final Exception e) { + throw new RuntimeException("Failed to read " + resourceName + " from resources", e); + } + } + + static class FakeSchemaReferenceWriter extends AbstractControllerService implements SchemaReferenceWriter { + @Override + public void writeHeader(final RecordSchema recordSchema, final OutputStream outputStream) throws IOException { + outputStream.write(FAKE_HEADER); + } + + @Override + public Map getAttributes(final RecordSchema recordSchema) { + return Map.of(); + } + + @Override + public void validateSchema(final RecordSchema recordSchema) { + } + + @Override + public Set getRequiredSchemaFields() { + return emptySet(); + } + } + + static class FakeMessageIndexWriter extends AbstractControllerService implements MessageIndexWriter { + @Override + public void writeMessageIndex(final Map variables, final SchemaDefinition schemaDefinition, final MessageName messageName, final OutputStream outputStream) throws IOException { + outputStream.write(FAKE_INDEX); + } + } +} diff --git a/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/test/java/org/apache/nifi/services/protobuf/TestStandardProtobufWriterConfluentRoundTrip.java b/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/test/java/org/apache/nifi/services/protobuf/TestStandardProtobufWriterConfluentRoundTrip.java new file mode 100644 index 000000000000..56925f282116 --- /dev/null +++ b/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/test/java/org/apache/nifi/services/protobuf/TestStandardProtobufWriterConfluentRoundTrip.java @@ -0,0 +1,235 @@ +/* + * 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 org.apache.nifi.confluent.schemaregistry.ConfluentEncodedSchemaReferenceReader; +import org.apache.nifi.confluent.schemaregistry.ConfluentEncodedSchemaReferenceWriter; +import org.apache.nifi.confluent.schemaregistry.ConfluentProtobufMessageIndexWriter; +import org.apache.nifi.confluent.schemaregistry.ConfluentProtobufMessageNameResolver; +import org.apache.nifi.controller.AbstractControllerService; +import org.apache.nifi.controller.ControllerService; +import org.apache.nifi.schema.access.SchemaField; +import org.apache.nifi.schema.access.SchemaNotFoundException; +import org.apache.nifi.schemaregistry.services.SchemaDefinition; +import org.apache.nifi.schemaregistry.services.SchemaRegistry; +import org.apache.nifi.schemaregistry.services.StandardSchemaDefinition; +import org.apache.nifi.serialization.RecordReader; +import org.apache.nifi.serialization.RecordSetWriter; +import org.apache.nifi.serialization.SimpleRecordSchema; +import org.apache.nifi.serialization.record.MapRecord; +import org.apache.nifi.serialization.record.Record; +import org.apache.nifi.serialization.record.RecordField; +import org.apache.nifi.serialization.record.RecordFieldType; +import org.apache.nifi.serialization.record.RecordSchema; +import org.apache.nifi.serialization.record.SchemaIdentifier; +import org.apache.nifi.util.NoOpProcessor; +import org.apache.nifi.util.TestRunner; +import org.apache.nifi.util.TestRunners; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static java.util.Collections.emptyMap; +import static java.util.Collections.emptySet; +import static org.apache.nifi.schema.access.SchemaAccessUtils.SCHEMA_ACCESS_STRATEGY; +import static org.apache.nifi.schema.access.SchemaAccessUtils.SCHEMA_NAME; +import static org.apache.nifi.schema.access.SchemaAccessUtils.SCHEMA_NAME_PROPERTY; +import static org.apache.nifi.schema.access.SchemaAccessUtils.SCHEMA_REFERENCE_READER; +import static org.apache.nifi.schema.access.SchemaAccessUtils.SCHEMA_REFERENCE_READER_PROPERTY; +import static org.apache.nifi.schema.access.SchemaAccessUtils.SCHEMA_REGISTRY; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * End-to-end Confluent wire-format tests: {@link StandardProtobufWriter} configured with the real + * {@link ConfluentEncodedSchemaReferenceWriter} and {@link ConfluentProtobufMessageIndexWriter} produces + * [header][message index][payload], with exact byte assertions on the header and index (including the + * single-byte [0] optimization and a nested-message index), and the content round-trips back through + * {@link StandardProtobufReader} using the matching {@link ConfluentEncodedSchemaReferenceReader} and + * {@link ConfluentProtobufMessageNameResolver}. + */ +class TestStandardProtobufWriterConfluentRoundTrip { + + private static final int SCHEMA_ID = 42; + // Confluent header: magic byte 0x00 followed by the big-endian schema id (42). + private static final byte[] EXPECTED_HEADER = {0x00, 0x00, 0x00, 0x00, 0x2A}; + + private static final String SCHEMA_TEXT = """ + syntax = "proto3"; + message User { + int32 id = 1; + string name = 2; + message Profile { + string bio = 1; + } + } + message Company { + string name = 1; + }"""; + + private TestRunner runner; + private MockSchemaRegistry schemaRegistry; + private ConfluentEncodedSchemaReferenceWriter referenceWriter; + private ConfluentProtobufMessageIndexWriter indexWriter; + private ConfluentEncodedSchemaReferenceReader referenceReader; + private ConfluentProtobufMessageNameResolver messageNameResolver; + + @BeforeEach + void setUp() throws Exception { + runner = TestRunners.newTestRunner(NoOpProcessor.class); + + schemaRegistry = new MockSchemaRegistry(); + referenceWriter = new ConfluentEncodedSchemaReferenceWriter(); + indexWriter = new ConfluentProtobufMessageIndexWriter(); + referenceReader = new ConfluentEncodedSchemaReferenceReader(); + messageNameResolver = new ConfluentProtobufMessageNameResolver(); + + enableService("registry", schemaRegistry); + enableService("referenceWriter", referenceWriter); + enableService("indexWriter", indexWriter); + enableService("referenceReader", referenceReader); + enableService("messageNameResolver", messageNameResolver); + } + + @Test + void testFirstRootMessageSingleByteIndexAndRoundTrip() throws Exception { + final Map values = new HashMap<>(); + values.put("id", 7); + values.put("name", "Alice"); + final MapRecord userRecord = new MapRecord(recordSchema("id", "name"), values); + + final byte[] output = writeConfluent("User", userRecord); + + // [header][message index][payload]; the first root message collapses to the single index byte 0x00. + assertArrayEquals(EXPECTED_HEADER, slice(output, 0, 5)); + assertEquals(0x00, output[5]); + + final Record readBack = readConfluent(output); + assertEquals(7, readBack.getValue("id")); + assertEquals("Alice", readBack.getValue("name")); + } + + @Test + void testNestedMessageIndexAndRoundTrip() throws Exception { + final Map values = new HashMap<>(); + values.put("bio", "hello"); + final MapRecord profileRecord = new MapRecord(recordSchema("bio"), values); + + final byte[] output = writeConfluent("User.Profile", profileRecord); + + // Nested message User.Profile -> declaration path [0, 0] -> length 2 then indexes 0, 0 (zigzag varints). + assertArrayEquals(EXPECTED_HEADER, slice(output, 0, 5)); + assertArrayEquals(new byte[] {0x04, 0x00, 0x00}, slice(output, 5, 8)); + + final Record readBack = readConfluent(output); + assertEquals("hello", readBack.getValue("bio")); + } + + private byte[] writeConfluent(final String messageName, final MapRecord record) throws Exception { + final StandardProtobufWriter writer = new StandardProtobufWriter(); + runner.addControllerService("writer-" + messageName, writer); + runner.setProperty(writer, SCHEMA_ACCESS_STRATEGY, SCHEMA_NAME_PROPERTY.getValue()); + runner.setProperty(writer, SCHEMA_REGISTRY, "registry"); + runner.setProperty(writer, SCHEMA_NAME, "user"); + runner.setProperty(writer, StandardProtobufWriter.MESSAGE_NAME_RESOLUTION_STRATEGY, + StandardProtobufWriter.MessageNameResolverStrategy.MESSAGE_NAME_PROPERTY.getValue()); + runner.setProperty(writer, StandardProtobufWriter.MESSAGE_NAME, messageName); + runner.setProperty(writer, StandardProtobufWriter.SCHEMA_REFERENCE_WRITER, "referenceWriter"); + runner.setProperty(writer, StandardProtobufWriter.MESSAGE_INDEX_WRITER, "indexWriter"); + runner.enableControllerService(writer); + + final RecordSchema writeSchema = writer.getSchema(emptyMap(), null); + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (RecordSetWriter recordSetWriter = writer.createWriter(runner.getLogger(), writeSchema, out, emptyMap())) { + recordSetWriter.beginRecordSet(); + recordSetWriter.write(record); + recordSetWriter.finishRecordSet(); + recordSetWriter.flush(); + } + return out.toByteArray(); + } + + private Record readConfluent(final byte[] data) throws Exception { + final StandardProtobufReader reader = new StandardProtobufReader(); + runner.addControllerService("reader", reader); + runner.setProperty(reader, SCHEMA_ACCESS_STRATEGY, SCHEMA_REFERENCE_READER_PROPERTY.getValue()); + runner.setProperty(reader, SCHEMA_REFERENCE_READER, "referenceReader"); + runner.setProperty(reader, SCHEMA_REGISTRY, "registry"); + runner.setProperty(reader, StandardProtobufReader.MESSAGE_NAME_RESOLUTION_STRATEGY, + StandardProtobufReader.MessageNameResolverStrategy.MESSAGE_NAME_RESOLVER.getValue()); + runner.setProperty(reader, StandardProtobufReader.MESSAGE_NAME_RESOLVER, "messageNameResolver"); + runner.enableControllerService(reader); + + final RecordReader recordReader = reader.createRecordReader(emptyMap(), new ByteArrayInputStream(data), data.length, runner.getLogger()); + final Record record = recordReader.nextRecord(); + assertNull(recordReader.nextRecord()); + return record; + } + + private RecordSchema recordSchema(final String... fieldNames) { + final RecordField[] fields = new RecordField[fieldNames.length]; + for (int i = 0; i < fieldNames.length; i++) { + final RecordFieldType type = "id".equals(fieldNames[i]) ? RecordFieldType.INT : RecordFieldType.STRING; + fields[i] = new RecordField(fieldNames[i], type.getDataType()); + } + return new SimpleRecordSchema(List.of(fields)); + } + + private void enableService(final String id, final ControllerService service) throws Exception { + runner.addControllerService(id, service); + runner.enableControllerService(service); + } + + private byte[] slice(final byte[] data, final int from, final int to) { + final byte[] out = new byte[to - from]; + System.arraycopy(data, from, out, 0, to - from); + return out; + } + + /** + * Returns a fixed Protobuf SchemaDefinition (with a numeric schema id and version) for any identifier, so that + * both the writer's name-based lookup and the reader's Confluent-header-based lookup resolve the same schema. + */ + static class MockSchemaRegistry extends AbstractControllerService implements SchemaRegistry { + private final SchemaDefinition schemaDefinition = new StandardSchemaDefinition( + SchemaIdentifier.builder().name("user.proto").id((long) SCHEMA_ID).version(1).build(), + SCHEMA_TEXT, + SchemaDefinition.SchemaType.PROTOBUF); + + @Override + public RecordSchema retrieveSchema(final SchemaIdentifier schemaIdentifier) { + throw new UnsupportedOperationException("retrieveSchema is not used in this test"); + } + + @Override + public SchemaDefinition retrieveSchemaDefinition(final SchemaIdentifier schemaIdentifier) throws SchemaNotFoundException { + return schemaDefinition; + } + + @Override + public Set getSuppliedSchemaFields() { + return emptySet(); + } + } +} diff --git a/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/test/java/org/apache/nifi/services/protobuf/converter/TestProtobufDataSerializer.java b/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/test/java/org/apache/nifi/services/protobuf/converter/TestProtobufDataSerializer.java new file mode 100644 index 000000000000..24d8e36fe405 --- /dev/null +++ b/nifi-extension-bundles/nifi-protobuf-bundle/nifi-protobuf-services/src/test/java/org/apache/nifi/services/protobuf/converter/TestProtobufDataSerializer.java @@ -0,0 +1,151 @@ +/* + * 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.converter; + +import com.google.protobuf.Descriptors.DescriptorValidationException; +import com.squareup.wire.schema.Schema; +import org.apache.nifi.serialization.record.MapRecord; +import org.apache.nifi.serialization.record.RecordSchema; +import org.apache.nifi.services.protobuf.ProtoTestUtil; +import org.apache.nifi.services.protobuf.schema.ProtoSchemaParser; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.math.BigInteger; +import java.util.Map; + +import static org.apache.nifi.services.protobuf.ProtoTestUtil.generateInputDataForRootMessage; +import static org.apache.nifi.services.protobuf.ProtoTestUtil.loadProto3TestSchema; +import static org.apache.nifi.services.protobuf.ProtoTestUtil.loadRepeatedProto3TestSchema; +import static org.apache.nifi.services.protobuf.ProtoTestUtil.loadRootMessageSchema; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Validates {@link ProtobufDataSerializer} by round-tripping through the reader-side + * {@link ProtobufDataConverter}: a Record is produced from known protobuf bytes, re-serialized by + * the serializer, and parsed back, asserting the values survive the round trip losslessly. Runs + * without the NiFi framework (no TestRunner). + */ +public class TestProtobufDataSerializer { + + @Test + public void testSerializeProto3RoundTrip() throws DescriptorValidationException, IOException { + final Schema schema = loadProto3TestSchema(); + final RecordSchema recordSchema = new ProtoSchemaParser(schema).createSchema("Proto3Message"); + + final MapRecord originalRecord = new ProtobufDataConverter(schema, "Proto3Message", recordSchema, false, false) + .createRecord(ProtoTestUtil.generateInputDataForProto3()); + + final byte[] serialized = new ProtobufDataSerializer(schema, "Proto3Message").serialize(originalRecord); + + final MapRecord record = new ProtobufDataConverter(schema, "Proto3Message", recordSchema, false, false) + .createRecord(new ByteArrayInputStream(serialized)); + + assertEquals(true, record.getValue("booleanField")); + assertEquals("Test text", record.getValue("stringField")); + assertEquals(Integer.MAX_VALUE, record.getValue("int32Field")); + assertEquals(4294967295L, record.getValue("uint32Field")); + assertEquals(Integer.MIN_VALUE, record.getValue("sint32Field")); + assertEquals(4294967294L, record.getValue("fixed32Field")); + assertEquals(Integer.MAX_VALUE, record.getValue("sfixed32Field")); + assertEquals(Double.MAX_VALUE, record.getValue("doubleField")); + assertEquals(Float.MAX_VALUE, record.getValue("floatField")); + assertArrayEquals("Test bytes".getBytes(), (byte[]) record.getValue("bytesField")); + assertEquals(Long.MAX_VALUE, record.getValue("int64Field")); + assertEquals(new BigInteger("18446744073709551615"), record.getValue("uint64Field")); + assertEquals(Long.MIN_VALUE, record.getValue("sint64Field")); + assertEquals(new BigInteger("18446744073709551614"), record.getValue("fixed64Field")); + assertEquals(Long.MAX_VALUE, record.getValue("sfixed64Field")); + + final MapRecord nestedRecord = (MapRecord) record.getValue("nestedMessage"); + assertEquals("ENUM_VALUE_3", nestedRecord.getValue("testEnum")); + + final Object[] recordList = (Object[]) nestedRecord.getValue("nestedMessage2"); + assertEquals(1, recordList.length); + + final MapRecord nestedRecord2 = (MapRecord) recordList[0]; + assertEquals(Map.of("test_key_entry1", 101, "test_key_entry2", 202), nestedRecord2.getValue("testMap")); + + // Only one field is set in the OneOf field + assertNull(nestedRecord2.getValue("stringOption")); + assertNull(nestedRecord2.getValue("booleanOption")); + assertEquals(3, nestedRecord2.getValue("int32Option")); + } + + @Test + public void testSerializeNestedMessageRoundTrip() throws DescriptorValidationException, IOException { + final Schema schema = loadRootMessageSchema(); + final String messageName = "org.apache.nifi.protobuf.test.RootMessage"; + final RecordSchema recordSchema = new ProtoSchemaParser(schema).createSchema(messageName); + + final MapRecord originalRecord = new ProtobufDataConverter(schema, messageName, recordSchema, false, false) + .createRecord(generateInputDataForRootMessage()); + + final byte[] serialized = new ProtobufDataSerializer(schema, messageName).serialize(originalRecord); + + final MapRecord record = new ProtobufDataConverter(schema, messageName, recordSchema, false, false) + .createRecord(new ByteArrayInputStream(serialized)); + + final MapRecord nestedRecord = (MapRecord) record.getValue("nestedMessage"); + assertEquals("ENUM_VALUE_3", nestedRecord.getValue("testEnum")); + } + + @Test + public void testSerializeRepeatedProto3RoundTrip() throws DescriptorValidationException, IOException { + final Schema schema = loadRepeatedProto3TestSchema(); + final RecordSchema recordSchema = new ProtoSchemaParser(schema).createSchema("RootMessage"); + + final MapRecord originalRecord = new ProtobufDataConverter(schema, "RootMessage", recordSchema, false, false) + .createRecord(ProtoTestUtil.generateInputDataForRepeatedProto3()); + + final byte[] serialized = new ProtobufDataSerializer(schema, "RootMessage").serialize(originalRecord); + + final MapRecord record = new ProtobufDataConverter(schema, "RootMessage", recordSchema, false, false) + .createRecord(new ByteArrayInputStream(serialized)); + + final Object[] repeatedMessage = (Object[]) record.getValue("repeatedMessage"); + final MapRecord record1 = (MapRecord) repeatedMessage[0]; + + assertArrayEquals(new Object[]{true, false}, (Object[]) record1.getValue("booleanField")); + assertArrayEquals(new Object[]{"Test text1", "Test text2"}, (Object[]) record1.getValue("stringField")); + assertArrayEquals(new Object[]{Integer.MAX_VALUE, Integer.MAX_VALUE - 1}, (Object[]) record1.getValue("int32Field")); + assertArrayEquals(new Object[]{4294967295L, 4294967294L}, (Object[]) record1.getValue("uint32Field")); + assertArrayEquals(new Object[]{Integer.MIN_VALUE, Integer.MIN_VALUE + 1}, (Object[]) record1.getValue("sint32Field")); + assertArrayEquals(new Object[]{4294967294L, 4294967293L}, (Object[]) record1.getValue("fixed32Field")); + assertArrayEquals(new Object[]{Integer.MAX_VALUE, Integer.MAX_VALUE - 1}, (Object[]) record1.getValue("sfixed32Field")); + assertArrayEquals(new Object[]{Double.MAX_VALUE, Double.MAX_VALUE - 1}, (Object[]) record1.getValue("doubleField")); + assertArrayEquals(new Object[]{Float.MAX_VALUE, Float.MAX_VALUE - 1}, (Object[]) record1.getValue("floatField")); + assertArrayEquals(new Object[]{Long.MAX_VALUE, Long.MAX_VALUE - 1}, (Object[]) record1.getValue("int64Field")); + assertArrayEquals(new Object[]{Long.MIN_VALUE, Long.MIN_VALUE + 1}, (Object[]) record1.getValue("sint64Field")); + assertArrayEquals(new Object[]{Long.MAX_VALUE, Long.MAX_VALUE - 1}, (Object[]) record1.getValue("sfixed64Field")); + assertArrayEquals(new Object[]{"ENUM_VALUE_2", "ENUM_VALUE_3"}, (Object[]) record1.getValue("testEnum")); + + final Object[] uint64FieldValues = (Object[]) record1.getValue("uint64Field"); + assertEquals(new BigInteger("18446744073709551615"), uint64FieldValues[0]); + assertEquals(new BigInteger("18446744073709551614"), uint64FieldValues[1]); + + final Object[] bytesFieldValues = (Object[]) record1.getValue("bytesField"); + assertArrayEquals("Test bytes1".getBytes(), (byte[]) bytesFieldValues[0]); + assertArrayEquals("Test bytes2".getBytes(), (byte[]) bytesFieldValues[1]); + + final MapRecord record2 = (MapRecord) repeatedMessage[1]; + assertArrayEquals(new Object[]{true}, (Object[]) record2.getValue("booleanField")); + } +} diff --git a/nifi-extension-bundles/nifi-standard-services/nifi-schema-registry-service-api/src/main/java/org/apache/nifi/schemaregistry/services/MessageIndexWriter.java b/nifi-extension-bundles/nifi-standard-services/nifi-schema-registry-service-api/src/main/java/org/apache/nifi/schemaregistry/services/MessageIndexWriter.java new file mode 100644 index 000000000000..3d3d2e83da12 --- /dev/null +++ b/nifi-extension-bundles/nifi-standard-services/nifi-schema-registry-service-api/src/main/java/org/apache/nifi/schemaregistry/services/MessageIndexWriter.java @@ -0,0 +1,54 @@ +/* + * 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.schemaregistry.services; + +import org.apache.nifi.controller.ControllerService; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.Map; + +/** + * An interface for writing message index information that locates a specific message within + * a schema definition containing multiple message declarations. + *

+ * This is the write-side counterpart to {@link MessageNameResolver}: given a message name that + * has already been determined, it encodes the path to that message within the schema definition + * and writes it to an output stream, enabling a reader to later resolve the same message name + * from the encoded path. + *

+ */ +public interface MessageIndexWriter extends ControllerService { + + /** + * Writes the message index identifying the given message name within the provided schema + * definition to the provided output stream. + *

+ * This method analyzes the given schema definition to determine the path of the target + * message and encodes that path to the output stream. The encoding strategy depends on the + * specific implementation and may involve navigating nested message declarations or + * consulting schema metadata. + *

+ * + * @param variables additional variables that may influence the encoding process, such as context-specific information + * @param schemaDefinition the schema definition containing schema information and metadata + * @param messageName the name of the message whose index should be written + * @param outputStream the output stream to which the message index should be written + * @throws IOException if an I/O error occurs while writing to the output stream or processing the schema + */ + void writeMessageIndex(final Map variables, final SchemaDefinition schemaDefinition, final MessageName messageName, final OutputStream outputStream) throws IOException; +}