From a724b168c38b741eb4cd7811726dc37646dd6fed Mon Sep 17 00:00:00 2001 From: Jessie Chen Date: Tue, 25 Jul 2023 21:59:07 -0700 Subject: [PATCH 01/41] Created MM2 Converter and Added Unit Tests --- common/pom.xml | 8 + .../common/AWSSchemaRegistryClient.java | 15 +- .../GlueSchemaRegistryConfiguration.java | 62 +++- .../utils/AWSSchemaRegistryConstants.java | 23 +- .../GlueSchemaRegistryConfigurationTest.java | 53 +-- .../.gitignore | 38 ++ .../pom.xml | 164 +++++++++ .../CrossRegionReplicationMM2Converter.java | 161 ++++++++ ...ssRegionReplicationMM2ConverterConfig.java | 20 + .../resources/META-INF/maven/archetype.xml | 9 + .../resources/archetype-resources/pom.xml | 15 + .../src/main/java/App.java | 13 + .../src/test/java/AppTest.java | 38 ++ ...rossRegionReplicationMM2ConverterTest.java | 344 ++++++++++++++++++ pom.xml | 1 + 15 files changed, 906 insertions(+), 58 deletions(-) create mode 100644 cross-region-replication-mm2-converter/.gitignore create mode 100644 cross-region-replication-mm2-converter/pom.xml create mode 100644 cross-region-replication-mm2-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2Converter.java create mode 100644 cross-region-replication-mm2-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2ConverterConfig.java create mode 100644 cross-region-replication-mm2-converter/src/main/resources/META-INF/maven/archetype.xml create mode 100644 cross-region-replication-mm2-converter/src/main/resources/archetype-resources/pom.xml create mode 100644 cross-region-replication-mm2-converter/src/main/resources/archetype-resources/src/main/java/App.java create mode 100644 cross-region-replication-mm2-converter/src/main/resources/archetype-resources/src/test/java/AppTest.java create mode 100644 cross-region-replication-mm2-converter/src/test/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2ConverterTest.java diff --git a/common/pom.xml b/common/pom.xml index e0bfec40..3b793447 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -65,6 +65,14 @@ software.amazon.awssdk glue + + + org.apache.maven.plugins + maven-shade-plugin + 3.2.1 + maven-plugin + + ${parent.groupId} schema-registry-build-tools diff --git a/common/src/main/java/com/amazonaws/services/schemaregistry/common/AWSSchemaRegistryClient.java b/common/src/main/java/com/amazonaws/services/schemaregistry/common/AWSSchemaRegistryClient.java index 949e6563..b3b48af5 100644 --- a/common/src/main/java/com/amazonaws/services/schemaregistry/common/AWSSchemaRegistryClient.java +++ b/common/src/main/java/com/amazonaws/services/schemaregistry/common/AWSSchemaRegistryClient.java @@ -90,18 +90,19 @@ public AWSSchemaRegistryClient(@NonNull AwsCredentialsProvider credentialsProvid .retryPolicy(retryPolicy) .addExecutionInterceptor(new UserAgentRequestInterceptor()) .build(); - UrlConnectionHttpClient.Builder urlConnectionHttpClientBuilder = UrlConnectionHttpClient.builder(); - if (glueSchemaRegistryConfiguration.getProxyUrl() != null) { - log.debug("Creating http client using proxy {}", glueSchemaRegistryConfiguration.getProxyUrl().toString()); - ProxyConfiguration proxy = ProxyConfiguration.builder().endpoint(glueSchemaRegistryConfiguration.getProxyUrl()).build(); - urlConnectionHttpClientBuilder.proxyConfiguration(proxy); - } +// UrlConnectionHttpClient.Builder urlConnectionHttpClientBuilder = UrlConnectionHttpClient.builder(); +// if (glueSchemaRegistryConfiguration.getProxyUrl() != null) { +// log.debug("Creating http client using proxy {}", glueSchemaRegistryConfiguration.getProxyUrl().toString()); +// ProxyConfiguration proxy = ProxyConfiguration.builder().endpoint(glueSchemaRegistryConfiguration.getProxyUrl()).build(); +// urlConnectionHttpClientBuilder.proxyConfiguration(proxy); +// } GlueClientBuilder glueClientBuilder = GlueClient .builder() .credentialsProvider(credentialsProvider) .overrideConfiguration(overrideConfiguration) - .httpClient(urlConnectionHttpClientBuilder.build()) +// .httpClient(urlConnectionHttpClientBuilder.build()) + .httpClient(UrlConnectionHttpClient.builder().build()) .region(Region.of(glueSchemaRegistryConfiguration.getRegion())); if (glueSchemaRegistryConfiguration.getEndPoint() != null) { diff --git a/common/src/main/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfiguration.java b/common/src/main/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfiguration.java index 4df13f50..80c215a7 100644 --- a/common/src/main/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfiguration.java +++ b/common/src/main/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfiguration.java @@ -27,7 +27,6 @@ import org.apache.commons.lang3.EnumUtils; import software.amazon.awssdk.services.glue.model.Compatibility; -import java.net.URI; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -44,6 +43,10 @@ public class GlueSchemaRegistryConfiguration { private AWSSchemaRegistryConstants.COMPRESSION compressionType = AWSSchemaRegistryConstants.COMPRESSION.NONE; private String endPoint; private String region; + private String srcEndPoint; + private String srcRegion; + private String tgtEndPoint; + private String tgtRegion; private long timeToLiveMillis = 24 * 60 * 60 * 1000L; private int cacheSize = 200; private AvroRecordType avroRecordType; @@ -55,7 +58,6 @@ public class GlueSchemaRegistryConfiguration { private Map tags = new HashMap<>(); private Map metadata; private String secondaryDeserializer; - private URI proxyUrl; /** * Name of the application using the serializer/deserializer. @@ -86,6 +88,10 @@ private void buildConfigs(Map configs) { } private void buildSchemaRegistryConfigs(Map configs) { + validateAndSetAWSSrcRegion(configs); + validateAndSetAWSSrcEndpoint(configs); + validateAndSetAWSTgtRegion(configs); + validateAndSetAWSTgtEndpoint(configs); validateAndSetAWSRegion(configs); validateAndSetAWSEndpoint(configs); validateAndSetRegistryName(configs); @@ -101,7 +107,6 @@ private void buildSchemaRegistryConfigs(Map configs) { validateAndSetMetadata(configs); validateAndSetUserAgent(configs); validateAndSetSecondaryDeserializer(configs); - validateAndSetProxyUrl(configs); } private void validateAndSetSecondaryDeserializer(Map configs) { @@ -140,7 +145,7 @@ private boolean validateCompressionType(String compressionType) { if (!EnumUtils.isValidEnum(AWSSchemaRegistryConstants.COMPRESSION.class, compressionType.toUpperCase())) { String errorMessage = String.format("Invalid Compression type : %s, Accepted values are : %s", compressionType, - AWSSchemaRegistryConstants.COMPRESSION.values()); + AWSSchemaRegistryConstants.COMPRESSION.values()); throw new AWSSchemaRegistryException(errorMessage); } return true; @@ -154,6 +159,20 @@ private void validateAndSetAWSRegion(Map configs) { } } + private void validateAndSetAWSSrcRegion(Map configs) { + if (isPresent(configs, AWSSchemaRegistryConstants.AWS_SRC_REGION)) { + this.srcRegion = String.valueOf(configs.get(AWSSchemaRegistryConstants.AWS_SRC_REGION)); + } + } + + private void validateAndSetAWSTgtRegion(Map configs) { + if (isPresent(configs, AWSSchemaRegistryConstants.AWS_TGT_REGION)) { + this.tgtRegion = String.valueOf(configs.get(AWSSchemaRegistryConstants.AWS_TGT_REGION)); + } else { + this.tgtRegion = String.valueOf(configs.get(AWSSchemaRegistryConstants.AWS_REGION)); + } + } + private void validateAndSetCompatibility(Map configs) { if (isPresent(configs, AWSSchemaRegistryConstants.COMPATIBILITY_SETTING)) { this.compatibilitySetting = Compatibility.fromValue( @@ -161,10 +180,10 @@ private void validateAndSetCompatibility(Map configs) { .toUpperCase()); if (this.compatibilitySetting == null - || this.compatibilitySetting == Compatibility.UNKNOWN_TO_SDK_VERSION) { + || this.compatibilitySetting == Compatibility.UNKNOWN_TO_SDK_VERSION) { String errorMessage = String.format("Invalid compatibility setting : %s, Accepted values are : %s", - configs.get(AWSSchemaRegistryConstants.COMPATIBILITY_SETTING), - Compatibility.knownValues()); + configs.get(AWSSchemaRegistryConstants.COMPATIBILITY_SETTING), + Compatibility.knownValues()); throw new AWSSchemaRegistryException(errorMessage); } } else { @@ -186,18 +205,21 @@ private void validateAndSetAWSEndpoint(Map configs) { } } - private void validateAndSetProxyUrl(Map configs) { - if (isPresent(configs, AWSSchemaRegistryConstants.PROXY_URL)) { - String value = (String) configs.get(AWSSchemaRegistryConstants.PROXY_URL); - try { - this.proxyUrl = URI.create(value); - } catch (IllegalArgumentException e) { - String message = String.format("Proxy URL property is not a valid URL: %s", value); - throw new AWSSchemaRegistryException(message, e); - } + private void validateAndSetAWSSrcEndpoint(Map configs) { + if (isPresent(configs, AWSSchemaRegistryConstants.AWS_SRC_ENDPOINT)) { + this.srcEndPoint = String.valueOf(configs.get(AWSSchemaRegistryConstants.AWS_SRC_ENDPOINT)); + } + } + + private void validateAndSetAWSTgtEndpoint(Map configs) { + if (isPresent(configs, AWSSchemaRegistryConstants.AWS_TGT_ENDPOINT)) { + this.tgtEndPoint = String.valueOf(configs.get(AWSSchemaRegistryConstants.AWS_TGT_ENDPOINT)); + } else { + this.tgtEndPoint = String.valueOf(configs.get(AWSSchemaRegistryConstants.AWS_ENDPOINT)); } } + private void validateAndSetDescription(Map configs) throws AWSSchemaRegistryException { if (isPresent(configs, AWSSchemaRegistryConstants.DESCRIPTION)) { this.description = String.valueOf(configs.get(AWSSchemaRegistryConstants.DESCRIPTION)); @@ -256,7 +278,7 @@ private void validateAndSetSchemaAutoRegistrationSetting(Map configs) .toString()); } else { log.info("schemaAutoRegistrationEnabled is not defined in the properties. Using the default value {}", - schemaAutoRegistrationEnabled); + schemaAutoRegistrationEnabled); } } @@ -325,9 +347,9 @@ private boolean isPresent(Map configs, private Map getMapFromPropertiesFile(Properties properties) { return new HashMap<>(properties.entrySet() - .stream() - .collect(Collectors.toMap(e -> e.getKey() - .toString(), e -> e.getValue()))); + .stream() + .collect(Collectors.toMap(e -> e.getKey() + .toString(), e -> e.getValue()))); } private String buildDescriptionFromProperties() throws AWSSchemaRegistryException { diff --git a/common/src/main/java/com/amazonaws/services/schemaregistry/utils/AWSSchemaRegistryConstants.java b/common/src/main/java/com/amazonaws/services/schemaregistry/utils/AWSSchemaRegistryConstants.java index 5ce30b06..6aaa0772 100644 --- a/common/src/main/java/com/amazonaws/services/schemaregistry/utils/AWSSchemaRegistryConstants.java +++ b/common/src/main/java/com/amazonaws/services/schemaregistry/utils/AWSSchemaRegistryConstants.java @@ -18,10 +18,6 @@ import software.amazon.awssdk.services.glue.model.Compatibility; public final class AWSSchemaRegistryConstants { - /** - * Proxy URL to use while connecting to AWS endpoint. - */ - public static final String PROXY_URL = "proxyUrl"; /** * AWS endpoint to use while initializing the client for service. */ @@ -30,6 +26,23 @@ public final class AWSSchemaRegistryConstants { * AWS region to use while initializing the client for service. */ public static final String AWS_REGION = "region"; + /** + * TODO: CR_GSR: AWS source endpoint to use while initializing the client for service. + */ + public static final String AWS_SRC_ENDPOINT = "source.endpoint"; + /** + * TODO: CR_GSR: AWS source region to use while initializing the client for service. + */ + public static final String AWS_SRC_REGION = "source.region"; + /** + * AWS target endpoint to use while initializing the client for service. + */ + public static final String AWS_TGT_ENDPOINT = "target.endpoint"; + /** + * AWS target region to use while initializing the client for service. + */ + public static final String AWS_TGT_REGION = "target.region"; + /** * Header Version Byte. */ @@ -216,4 +229,4 @@ public enum COMPRESSION { */ ZLIB } -} +} \ No newline at end of file diff --git a/common/src/test/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfigurationTest.java b/common/src/test/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfigurationTest.java index 9ae2bb09..90a3c581 100644 --- a/common/src/test/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfigurationTest.java +++ b/common/src/test/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfigurationTest.java @@ -257,8 +257,8 @@ public void testBuildConfig_invalidCompressionType_throwsException() { @Test public void testBuildConfig_validTags_succeeds() { Map testTags = new HashMap<>(); - testTags.put("testTagKey","testTagValue"); - testTags.put("testTagKey2","testTagValue2"); + testTags.put("testTagKey", "testTagValue"); + testTags.put("testTagKey2", "testTagValue2"); configs.put(AWSSchemaRegistryConstants.TAGS, testTags); GlueSchemaRegistryConfiguration serDeConfigs = new GlueSchemaRegistryConfiguration(configs); @@ -278,8 +278,8 @@ public void testBuildConfig_validTags_succeeds() { public void testBuildConfigWithProperties_validTags_succeeds() { Properties props = createTestProperties(); HashMap testTags = new HashMap<>(); - testTags.put("testTagKey","testTagValue"); - testTags.put("testTagKey2","testTagValue2"); + testTags.put("testTagKey", "testTagValue"); + testTags.put("testTagKey2", "testTagValue2"); props.put(AWSSchemaRegistryConstants.TAGS, testTags); GlueSchemaRegistryConfiguration serDeConfigs = new GlueSchemaRegistryConfiguration(props); @@ -370,27 +370,28 @@ public void testValidateAndSetRegistryName_withRegistryConfig_throwsException() assertEquals(expectedRegistryName, glueSchemaRegistryConfiguration.getRegistryName()); } - /** - * Tests valid proxy URL value. - */ - @Test - public void testBuildConfig_validProxyUrl_success() { - Properties props = createTestProperties(); - String proxy = "http://proxy.servers.url:8080"; - props.put(AWSSchemaRegistryConstants.PROXY_URL, proxy); - GlueSchemaRegistryConfiguration glueSchemaRegistryConfiguration = new GlueSchemaRegistryConfiguration(props); - assertEquals(URI.create(proxy), glueSchemaRegistryConfiguration.getProxyUrl()); - } - /** - * Tests invalid proxy URL value. - */ - @Test - public void testBuildConfig_invalidProxyUrl_throwsException() { - Properties props = createTestProperties(); - String proxy = "http:// proxy.url: 8080"; - props.put(AWSSchemaRegistryConstants.PROXY_URL, "http:// proxy.url: 8080"); - Exception exception = assertThrows(AWSSchemaRegistryException.class, () -> new GlueSchemaRegistryConfiguration(props)); - assertEquals("Proxy URL property is not a valid URL: "+proxy, exception.getMessage()); - } +// /** +// * Tests valid proxy URL value. +// */ +// @Test +// public void testBuildConfig_validProxyUrl_success() { +// Properties props = createTestProperties(); +// String proxy = "http://proxy.servers.url:8080"; +// props.put(AWSSchemaRegistryConstants.PROXY_URL, proxy); +// GlueSchemaRegistryConfiguration glueSchemaRegistryConfiguration = new GlueSchemaRegistryConfiguration(props); +// assertEquals(URI.create(proxy), glueSchemaRegistryConfiguration.getProxyUrl()); +// } +// +// /** +// * Tests invalid proxy URL value. +// */ +// @Test +// public void testBuildConfig_invalidProxyUrl_throwsException() { +// Properties props = createTestProperties(); +// String proxy = "http:// proxy.url: 8080"; +// props.put(AWSSchemaRegistryConstants.PROXY_URL, "http:// proxy.url: 8080"); +// Exception exception = assertThrows(AWSSchemaRegistryException.class, () -> new GlueSchemaRegistryConfiguration(props)); +// assertEquals("Proxy URL property is not a valid URL: "+proxy, exception.getMessage()); +// } } \ No newline at end of file diff --git a/cross-region-replication-mm2-converter/.gitignore b/cross-region-replication-mm2-converter/.gitignore new file mode 100644 index 00000000..5ff6309b --- /dev/null +++ b/cross-region-replication-mm2-converter/.gitignore @@ -0,0 +1,38 @@ +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/cross-region-replication-mm2-converter/pom.xml b/cross-region-replication-mm2-converter/pom.xml new file mode 100644 index 00000000..583e526d --- /dev/null +++ b/cross-region-replication-mm2-converter/pom.xml @@ -0,0 +1,164 @@ + + + 4.0.0 + + org.example + CrossRegionReplicationMm2Converter + 1.0-SNAPSHOT + + + 20 + 20 + UTF-8 + + + + + org.apache.kafka + kafka-clients + 3.2.3 + + + + org.apache.kafka + kafka-streams + 3.2.3 + + + + org.apache.kafka + connect-api + 3.2.3 + + + + software.amazon.glue + schema-registry-serde + 1.1.15 + + + + software.amazon.glue + schema-registry-common + 1.1.15 + + + + org.apache.avro + avro + 1.7.7 + + + + + + + + + + + + + + + + com.amazonaws + aws-java-sdk-sts + 1.12.151 + + + software.amazon.awssdk + sts + 2.17.122 + + + software.amazon.glue + schema-registry-common + 1.1.15 + + + software.amazon.glue + schema-registry-kafkastreams-serde + 1.1.15 + + + software.amazon.glue + schema-registry-kafkaconnect-converter + 1.1.5 + + + software.amazon.awssdk + arns + 2.17.122 + + + + com.amazonaws + aws-java-sdk-glue + 1.12.497 + + + + com.google.protobuf + protobuf-java + 3.19.6 + test + + + + org.projectlombok + lombok + 1.18.24 + provided + + + + org.mockito + mockito-core + 5.3.1 + test + + + org.mockito + mockito-junit-jupiter + 5.3.1 + test + + + + junit + junit + 4.12 + test + + + org.junit.jupiter + junit-jupiter-engine + 5.9.1 + test + + + org.junit.jupiter + junit-jupiter-params + 5.9.1 + test + + + org.junit.platform + junit-platform-commons + 1.9.2 + test + + + org.junit.jupiter + junit-jupiter-api + 5.9.1 + test + + + + + + diff --git a/cross-region-replication-mm2-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2Converter.java b/cross-region-replication-mm2-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2Converter.java new file mode 100644 index 00000000..8ac614fe --- /dev/null +++ b/cross-region-replication-mm2-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2Converter.java @@ -0,0 +1,161 @@ +package com.amazonaws.services.crossregion.schemaregistry.kafkaconnect; + +import com.amazonaws.services.schemaregistry.common.AWSSerializerInput; +import com.amazonaws.services.schemaregistry.common.Schema; +import com.amazonaws.services.schemaregistry.common.configs.GlueSchemaRegistryConfiguration; +import com.amazonaws.services.schemaregistry.deserializers.GlueSchemaRegistryDeserializationFacade; +import com.amazonaws.services.schemaregistry.deserializers.GlueSchemaRegistryDeserializerImpl; +import com.amazonaws.services.schemaregistry.exception.AWSSchemaRegistryException; +import com.amazonaws.services.schemaregistry.serializers.GlueSchemaRegistrySerializationFacade; +import com.amazonaws.services.schemaregistry.serializers.GlueSchemaRegistrySerializerImpl; +import com.amazonaws.services.schemaregistry.utils.AWSSchemaRegistryConstants; + +import kotlinx.serialization.SerializationException; +import lombok.Getter; + +import org.apache.kafka.connect.data.*; +import org.apache.kafka.connect.errors.DataException; +import org.apache.kafka.connect.storage.Converter; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +public class CrossRegionReplicationMM2Converter implements Converter { + @Getter + private GlueSchemaRegistryDeserializationFacade deserializationFacade; + @Getter + private GlueSchemaRegistrySerializationFacade serializationFacade; + @Getter + private AwsCredentialsProvider credentialsProvider; + @Getter + private GlueSchemaRegistryDeserializerImpl deserializer; + @Getter + private GlueSchemaRegistrySerializerImpl serializer; + @Getter + private boolean isKey; + + /** + * Constructor used by Kafka Connect user. + */ + public CrossRegionReplicationMM2Converter(){}; + + /** + * Constructor accepting AWSCredentialsProvider. + * + * @param credentialsProvider AWSCredentialsProvider instance. + */ + public CrossRegionReplicationMM2Converter( + GlueSchemaRegistryDeserializationFacade deserializationFacade, + GlueSchemaRegistrySerializationFacade serializationFacade, + AwsCredentialsProvider credentialsProvider, + GlueSchemaRegistryDeserializerImpl deserializerImpl, + GlueSchemaRegistrySerializerImpl serializerImpl) { + this.deserializationFacade = deserializationFacade; + this.serializationFacade = serializationFacade; + this.credentialsProvider = credentialsProvider; + this.deserializer = deserializerImpl; + this.serializer = serializerImpl; + } + + /** + * Configure the MM2 Schema Replication Converter. + * @param configs configuration elements for the converter + * @param isKey true if key, false otherwise + */ + @Override + public void configure(Map configs, boolean isKey) { + this.isKey = isKey; + new CrossRegionReplicationMM2ConverterConfig(configs); + + + credentialsProvider = DefaultCredentialsProvider.builder().build(); + + // Put the source and target regions into configurations respectively + Map sourceConfigs = new HashMap<>(configs); + Map targetConfigs = new HashMap<>(configs); + + sourceConfigs.put(AWSSchemaRegistryConstants.AWS_REGION, configs.get(AWSSchemaRegistryConstants.AWS_SRC_REGION)); + targetConfigs.put(AWSSchemaRegistryConstants.AWS_REGION, configs.get(AWSSchemaRegistryConstants.AWS_TGT_REGION)); + + deserializationFacade = + GlueSchemaRegistryDeserializationFacade.builder() + .credentialProvider(credentialsProvider) + .configs(sourceConfigs) + .build(); + + serializationFacade = + GlueSchemaRegistrySerializationFacade.builder() + .credentialProvider(credentialsProvider) + .configs(targetConfigs) + .build(); + + serializer = new GlueSchemaRegistrySerializerImpl(credentialsProvider, new GlueSchemaRegistryConfiguration(targetConfigs)); + + deserializer = new GlueSchemaRegistryDeserializerImpl(credentialsProvider, new GlueSchemaRegistryConfiguration(sourceConfigs)); + + +// deserializationFacade = new GlueSchemaRegistryDeserializationFacade(new GlueSchemaRegistryConfiguration(sourceConfigs), credentialsProvider); +// +// serializationFacade = new GlueSchemaRegistrySerializationFacade(credentialsProvider, null, new GlueSchemaRegistryConfiguration(targetConfigs), null, null); + + + } + + @Override + public byte[] fromConnectData(String topic, org.apache.kafka.connect.data.Schema schema, Object value) { + byte[] bytes = (byte[]) value; + if (value == null) return new byte[0]; + + try { + byte[] deserializedBytes = deserializer.getData(bytes); + Schema deserializedSchema = deserializer.getSchema(bytes); + + byte[] encodedByte = serializer.encode(null, deserializedSchema, deserializedBytes); + com.amazonaws.services.schemaregistry.common.Schema returnedSchema = getSchema(bytes); + UUID uuid = registerSchema(returnedSchema); + + return encodedByte; + } catch (SerializationException | AWSSchemaRegistryException e){ + throw new DataException("Converting Kafka Connect data to byte[] failed due to serialization/deserialization error: ", e); + } + + } + + @Override + public SchemaAndValue toConnectData(String s, byte[] bytes) { + return null; + } + + /** + * Retrieve schema from source region GSR using schema header of the serialized messages + * @param data serialized message obtained from MM2 + * @return schema + */ + public Schema getSchema(byte[] data){ + if (data == null) { + throw new NullPointerException("Empty Data"); + } + return deserializationFacade.getSchema(data); + + } + + /** + * Register schema in the target region GSR + * @param schema schema obtained from the source region GSR + * @return schema version ID of the registered schema + */ + public UUID registerSchema(com.amazonaws.services.schemaregistry.common.Schema schema){ + try{ + String schemaDefinition = schema.getSchemaDefinition(); + String schemaName = schema.getSchemaName(); + String dataFormat = schema.getDataFormat(); + AWSSerializerInput input = new AWSSerializerInput(schemaDefinition, schemaName, dataFormat, null); + return serializationFacade.getOrRegisterSchemaVersion(input); + } catch (Exception e){ + throw new DataException("Schema can't be register"); + } + } +} diff --git a/cross-region-replication-mm2-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2ConverterConfig.java b/cross-region-replication-mm2-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2ConverterConfig.java new file mode 100644 index 00000000..1a66c4e6 --- /dev/null +++ b/cross-region-replication-mm2-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2ConverterConfig.java @@ -0,0 +1,20 @@ +package com.amazonaws.services.crossregion.schemaregistry.kafkaconnect; + +import org.apache.kafka.common.config.AbstractConfig; +import org.apache.kafka.common.config.ConfigDef; + +import java.util.Map; + +public class CrossRegionReplicationMM2ConverterConfig extends AbstractConfig { + + public static ConfigDef configDef() { + return new ConfigDef(); + } + /** + * Constructor used by CrossRegionReplicationMM2Converter. + * @param props property elements for the converter config + */ + public CrossRegionReplicationMM2ConverterConfig(Map props) { + super(configDef(), props); + } +} diff --git a/cross-region-replication-mm2-converter/src/main/resources/META-INF/maven/archetype.xml b/cross-region-replication-mm2-converter/src/main/resources/META-INF/maven/archetype.xml new file mode 100644 index 00000000..104a3b2c --- /dev/null +++ b/cross-region-replication-mm2-converter/src/main/resources/META-INF/maven/archetype.xml @@ -0,0 +1,9 @@ + + cross-region-replication-mm2-converter + + src/main/java/App.java + + + src/test/java/AppTest.java + + diff --git a/cross-region-replication-mm2-converter/src/main/resources/archetype-resources/pom.xml b/cross-region-replication-mm2-converter/src/main/resources/archetype-resources/pom.xml new file mode 100644 index 00000000..96f1bced --- /dev/null +++ b/cross-region-replication-mm2-converter/src/main/resources/archetype-resources/pom.xml @@ -0,0 +1,15 @@ + + 4.0.0 + $org.example + $cross-region-replication-mm2-converter + $1.1.15 + + + junit + junit + 3.8.1 + test + + + diff --git a/cross-region-replication-mm2-converter/src/main/resources/archetype-resources/src/main/java/App.java b/cross-region-replication-mm2-converter/src/main/resources/archetype-resources/src/main/java/App.java new file mode 100644 index 00000000..1fa6a956 --- /dev/null +++ b/cross-region-replication-mm2-converter/src/main/resources/archetype-resources/src/main/java/App.java @@ -0,0 +1,13 @@ +package $org.example; + +/** + * Hello world! + * + */ +public class App +{ + public static void main( String[] args ) + { + System.out.println( "Hello World!" ); + } +} diff --git a/cross-region-replication-mm2-converter/src/main/resources/archetype-resources/src/test/java/AppTest.java b/cross-region-replication-mm2-converter/src/main/resources/archetype-resources/src/test/java/AppTest.java new file mode 100644 index 00000000..65be417e --- /dev/null +++ b/cross-region-replication-mm2-converter/src/main/resources/archetype-resources/src/test/java/AppTest.java @@ -0,0 +1,38 @@ +package $org.example; + +import junit.framework.Test; +import junit.framework.TestCase; +import junit.framework.TestSuite; + +/** + * Unit test for simple App. + */ +public class AppTest + extends TestCase +{ + /** + * Create the test case + * + * @param testName name of the test case + */ + public AppTest( String testName ) + { + super( testName ); + } + + /** + * @return the suite of tests being tested + */ + public static Test suite() + { + return new TestSuite( AppTest.class ); + } + + /** + * Rigourous Test :-) + */ + public void testApp() + { + assertTrue( true ); + } +} diff --git a/cross-region-replication-mm2-converter/src/test/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2ConverterTest.java b/cross-region-replication-mm2-converter/src/test/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2ConverterTest.java new file mode 100644 index 00000000..a0175512 --- /dev/null +++ b/cross-region-replication-mm2-converter/src/test/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2ConverterTest.java @@ -0,0 +1,344 @@ +package com.amazonaws.services.crossregion.schemaregistry.kafkaconnect; + +import com.amazonaws.services.schemaregistry.common.AWSSerializerInput; +import com.amazonaws.services.schemaregistry.common.Schema; +import com.amazonaws.services.schemaregistry.deserializers.GlueSchemaRegistryDeserializationFacade; +import com.amazonaws.services.schemaregistry.deserializers.GlueSchemaRegistryDeserializerImpl; +import com.amazonaws.services.schemaregistry.exception.AWSSchemaRegistryException; +import com.amazonaws.services.schemaregistry.serializers.GlueSchemaRegistrySerializationFacade; +import com.amazonaws.services.schemaregistry.serializers.GlueSchemaRegistrySerializerImpl; +import com.amazonaws.services.schemaregistry.utils.AWSSchemaRegistryConstants; +import com.amazonaws.services.schemaregistry.utils.AvroRecordType; + +import org.apache.kafka.connect.data.Struct; +import org.apache.kafka.connect.data.SchemaBuilder; +import org.apache.kafka.connect.errors.DataException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.services.glue.model.DataFormat; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.when; + +/** + * Unit tests for testing RegisterSchema class. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) + +public class CrossRegionReplicationMM2ConverterTest { + @Mock + private GlueSchemaRegistryDeserializationFacade deserializationFacade; + @Mock + private GlueSchemaRegistrySerializationFacade serializationFacade; + @Mock + private AwsCredentialsProvider credProvider; + @Mock + private GlueSchemaRegistryDeserializerImpl deserializer; + @Mock + private GlueSchemaRegistrySerializerImpl serializer; + private final static byte[] ENCODED_DATA = new byte[] { 8, 9, 12, 83, 82 }; + private final static byte[] USER_DATA = new byte[] { 12, 83, 82 }; + private final static Schema SCHEMA_REGISTRY_SCHEMA = new Schema("{}", "AVRO", "schemaFoo"); + private static final String testTopic = "User-Topic"; + + byte[] genericBytes = new byte[] {3, 0, -73, -76, -89, -16, -100, -106, 78, 74, -90, -121, -5, + 93, -23, -17, 12, 99, 10, 115, 97, 110, 115, 97, -58, 1, 6, 114, 101, 100}; + + + byte[] avroBytes = new byte[] {3, 0, 84, 24, 47, -109, 37, 124, 74, 77, -100, + -98, -12, 118, 41, 32, 57, -66, 30, 101, 110, 116, 101, 114, 116, 97, 105, 110, 109, 101, 110, + 116, 95, 50, 0, 0, 0, 0, 0, 0, 20, 64}; + + private CrossRegionReplicationMM2Converter converter; + + + + @BeforeEach + void setUp() { + converter = new CrossRegionReplicationMM2Converter(deserializationFacade, serializationFacade, credProvider, deserializer, serializer); + } + + /** + * Test for Converter config method. + */ + @Test + public void testConverter_configure() { + converter = new CrossRegionReplicationMM2Converter(); + converter.configure(getTestProperties(), false); + assertNotNull(converter); + assertNotNull(converter.getDeserializationFacade()); + assertNotNull(converter.getSerializationFacade()); + assertNotNull(converter.getCredentialsProvider()); + assertNotNull(converter.getSerializer()); + assertNotNull(converter.getDeserializer()); + assertNotNull(converter.isKey()); + + } + + /** + * Test Mm2Converter when it returns byte 0 given the input value is null. + */ + @Test + public void testConverter_fromConnectData_returnsByte0() { + Struct expected = createStructRecord(); + assertEquals(Arrays.toString(converter.fromConnectData(testTopic, expected.schema(), null)), Arrays.toString(new byte[0])); + } + + /** + * Test Mm2Converter when serializer throws exception. + */ + @Test + public void testConverter_fromConnectData_throwsException() { + Struct expected = createStructRecord(); + doReturn(USER_DATA) + .when(deserializer).getData(genericBytes); + doReturn(SCHEMA_REGISTRY_SCHEMA) + .when(deserializer).getSchema(genericBytes); + when(serializer.encode(null, SCHEMA_REGISTRY_SCHEMA, USER_DATA)).thenThrow(new AWSSchemaRegistryException()); + assertThrows(DataException.class, () -> converter.fromConnectData(testTopic, expected.schema(), genericBytes)); + } + + /** + * Test Mm2Converter when the deserializer throws exception. + */ + @Test + public void testConverter_fromConnectData_deserializer_getData_ThrowsException() { + Struct expected = createStructRecord(); + when((deserializer).getData(genericBytes)).thenThrow(new AWSSchemaRegistryException()); + doReturn(SCHEMA_REGISTRY_SCHEMA) + .when(deserializer).getSchema(genericBytes); + doReturn(ENCODED_DATA) + .when(serializer).encode(null, SCHEMA_REGISTRY_SCHEMA, USER_DATA); + assertThrows(DataException.class, () -> converter.fromConnectData(testTopic, expected.schema(), genericBytes)); + } + + /** + * Test getSchema when NullPointerException is thrown. + */ + @Test + public void testGetSchema_nullObject_throwsException(){ + assertThrows(NullPointerException.class, () -> converter.getSchema(null)); + } + + + /** + * Test getSchema when an existing Avro schema is being successfully retrieved. + */ + @Test + public void getSchema_avro_succeeds(){ + + String schemaDefinition = """ + {"namespace": "com.amazonaws.services.schemaregistry.serializers.avro", + "type": "record", + "name": "payment", + "fields": [ + {"name": "id", "type": "string"}, + {"name": "id_6", "type": "double"} + ]}"""; + Schema testSchema = new Schema(schemaDefinition, DataFormat.AVRO.name(), testTopic); + + doReturn(testSchema). + when(deserializationFacade).getSchema(avroBytes); + Schema returnedSchema = converter.getSchema(avroBytes); + + assertEquals(returnedSchema.getSchemaDefinition(), testSchema.getSchemaDefinition()); + assertEquals(returnedSchema.getSchemaName(), testSchema.getSchemaName()); + assertEquals(returnedSchema.getDataFormat(), testSchema.getDataFormat()); + } + + /** + * Test getSchema when an existing JSON schema is being successfully retrieved. + */ + @Test + public void getSchema_json_succeeds() { + + String testSchemaDefinition = "{\"$id\":\"https://example.com/geographical-location.schema.json\"," + + "\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"title\":\"Longitude " + + "and Latitude Values\",\"description\":\"A geographical coordinate.\"," + + "\"required\":[\"latitude\",\"longitude\"],\"type\":\"object\"," + + "\"properties\":{\"latitude\":{\"type\":\"number\",\"minimum\":-90," + + "\"maximum\":90},\"longitude\":{\"type\":\"number\",\"minimum\":-180," + + "\"maximum\":180}},\"additionalProperties\":false}"; + + String jsonData = "{\"latitude\":48.858093,\"longitude\":2.294694}"; + byte[] jsonBytes = jsonData.getBytes(StandardCharsets.UTF_8); + Schema testSchema = new Schema(testSchemaDefinition, DataFormat.JSON.name(), testTopic); + + doReturn(testSchema). + when(deserializationFacade).getSchema(jsonBytes); + Schema returnedSchema = converter.getSchema(jsonBytes); + + assertEquals(returnedSchema.getSchemaDefinition(), testSchema.getSchemaDefinition()); + assertEquals(returnedSchema.getSchemaName(), testSchema.getSchemaName()); + assertEquals(returnedSchema.getDataFormat(), testSchema.getDataFormat()); + } + + /** + * Test getSchema when an existing Protobuf schema is being successfully retrieved. + */ + @Test + public void getSchema_protobuf_succeeds(){ + + Schema testSchema = new Schema("foo", DataFormat.PROTOBUF.name(), testTopic); + byte[] protobufBytes = "foo".getBytes(StandardCharsets.UTF_8); + + doReturn(testSchema). + when(deserializationFacade).getSchema(protobufBytes); + Schema returnedSchema = converter.getSchema(protobufBytes); + + assertEquals(returnedSchema.getSchemaDefinition(), testSchema.getSchemaDefinition()); + assertEquals(returnedSchema.getSchemaName(), testSchema.getSchemaName()); + assertEquals(returnedSchema.getDataFormat(), testSchema.getDataFormat()); + } + + /** + * Test registerSchema when NullPointerException is thrown. + */ + @Test + public void registerSchema_throwsDataException() { + assertThrows(DataException.class, () -> converter.registerSchema(null)); + } + + /** + * Test registerSchema for existing Avro schema. + */ + @Test + public void registerSchema_avro_nonExisting_succeeds() { + + String schemaDefinition = """ + {"namespace": "com.amazonaws.services.schemaregistry.serializers.avro", + "type": "record", + "name": "payment", + "fields": [ + {"name": "id", "type": "string"}, + {"name": "id_6", "type": "double"} + ]}"""; + UUID avroBytesVersionID = UUID.fromString("54182f93-257c-4a4d-9c9e-f476292039be"); + + Schema testSchema = new Schema(schemaDefinition, DataFormat.AVRO.name(), testTopic); + AWSSerializerInput input = new AWSSerializerInput(testSchema.getSchemaDefinition(), testSchema.getSchemaName(), testSchema.getDataFormat(), null); + + doReturn(avroBytesVersionID). + when(serializationFacade).getOrRegisterSchemaVersion(input); + + assertEquals(converter.registerSchema(testSchema), avroBytesVersionID); + } + + /** + * Test registerSchema for existing JSON schema. + */ + @Test + public void registerSchema_json_nonExisting_succeeds() { + String testSchemaDefinition = "{\"$id\":\"https://example.com/geographical-location.schema.json\"," + + "\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"title\":\"Longitude " + + "and Latitude Values\",\"description\":\"A geographical coordinate.\"," + + "\"required\":[\"latitude\",\"longitude\"],\"type\":\"object\"," + + "\"properties\":{\"latitude\":{\"type\":\"number\",\"minimum\":-90," + + "\"maximum\":90},\"longitude\":{\"type\":\"number\",\"minimum\":-180," + + "\"maximum\":180}},\"additionalProperties\":false}"; + UUID jsonBytesVersionID = UUID.fromString("afba13fd-7c25-4202-8904-1fab3089faf9"); + + Schema testSchema = new Schema(testSchemaDefinition, DataFormat.JSON.name(), "testJson"); + AWSSerializerInput input = new AWSSerializerInput(testSchema.getSchemaDefinition(), testSchema.getSchemaName(), testSchema.getDataFormat(), null); + + doReturn(jsonBytesVersionID). + when(serializationFacade).getOrRegisterSchemaVersion(input); + + assertEquals(converter.registerSchema(testSchema), jsonBytesVersionID); + } + + /** + * Test registerSchema for non-existing Protobuf schema. + */ + @Test + public void registerSchema_protobuf_nonExisting_succeeds() { + String testSchemaDefinition = "foo"; + UUID protobufBytesVersionID = UUID.fromString("b7b4a7f0-9c96-4e4a-a687-fb5de9ef0c63"); + + Schema testSchema = new Schema(testSchemaDefinition, DataFormat.PROTOBUF.name(), testTopic); + AWSSerializerInput input = new AWSSerializerInput(testSchema.getSchemaDefinition(), testSchema.getSchemaName(), testSchema.getDataFormat(), null); + + doReturn(protobufBytesVersionID). + when(serializationFacade).getOrRegisterSchemaVersion(input); + + assertEquals(converter.registerSchema(testSchema), protobufBytesVersionID); + } + + + /** + * To create a map of configurations w/o source region. + * + * @return a map of configurations + */ + private Map getNoSourceProperties() { + Map props = new HashMap<>(); + + props.put("TARGET_REGION", "us-west-2"); + props.put(AWSSchemaRegistryConstants.AWS_ENDPOINT, "https://test"); + props.put(AWSSchemaRegistryConstants.SCHEMA_AUTO_REGISTRATION_SETTING, true); + props.put(AWSSchemaRegistryConstants.AVRO_RECORD_TYPE, AvroRecordType.GENERIC_RECORD.getName()); + + return props; + } + + /** + * To create a map of configurations w/o source region. + * + * @return a map of configurations + */ + private Map getNoTargetProperties() { + Map props = new HashMap<>(); + + props.put("SOURCE_REGION", "us-west-2"); + props.put(AWSSchemaRegistryConstants.AWS_ENDPOINT, "https://test"); + props.put(AWSSchemaRegistryConstants.SCHEMA_AUTO_REGISTRATION_SETTING, true); + props.put(AWSSchemaRegistryConstants.AVRO_RECORD_TYPE, AvroRecordType.GENERIC_RECORD.getName()); + + return props; + } + + /** + * To create a map of configurations. + * + * @return a map of configurations + */ + private Map getTestProperties() { + Map props = new HashMap<>(); + + props.put("SOURCE_REGION", "us-west-2"); + props.put("TARGET_REGION", "us-east-1"); + props.put(AWSSchemaRegistryConstants.REGISTRY_NAME, "default-registry"); + props.put(AWSSchemaRegistryConstants.SCHEMA_NAME, "t2"); + props.put(AWSSchemaRegistryConstants.AWS_ENDPOINT, "https://test"); + props.put(AWSSchemaRegistryConstants.AWS_SRC_ENDPOINT, "https://test"); + props.put(AWSSchemaRegistryConstants.SCHEMA_AUTO_REGISTRATION_SETTING, true); + props.put(AWSSchemaRegistryConstants.AVRO_RECORD_TYPE, AvroRecordType.GENERIC_RECORD.getName()); + + return props; + } + + /** + * To create a Connect Struct record. + * + * @return a Connect Struct + */ + private Struct createStructRecord() { + org.apache.kafka.connect.data.Schema schema = SchemaBuilder.struct() + .build(); + return new Struct(schema); + } +} diff --git a/pom.xml b/pom.xml index 582e6ef1..b843cecc 100644 --- a/pom.xml +++ b/pom.xml @@ -74,6 +74,7 @@ integration-tests jsonschema-kafkaconnect-converter protobuf-kafkaconnect-converter + cross-region-replication-mm2-converter From f26a1586436341713c0d2c9380b10374c125fa8e Mon Sep 17 00:00:00 2001 From: Jessie Chen Date: Wed, 26 Jul 2023 13:55:03 -0700 Subject: [PATCH 02/41] Modified Converter Based on First CR Feedback --- .gitignore | 3 +- common/pom.xml | 8 - .../common/AWSSchemaRegistryClient.java | 15 +- .../GlueSchemaRegistryConfiguration.java | 75 +- .../utils/AWSSchemaRegistryConstants.java | 15 +- .../GlueSchemaRegistryConfigurationTest.java | 53 +- .../.gitignore | 2 + cross-region-replication-converter/pom.xml | 229 +++++ .../CrossRegionReplicationConverter.java | 103 ++ .../src/test/avro/AvroMessage.avsc | 944 ++++++++++++++++++ .../src/test/avro/DocTestRecord.avsc | 20 + .../src/test/avro/Enum.avsc | 16 + .../src/test/avro/EnumUnion.avsc | 22 + .../src/test/avro/MultiTypeUnionMessage.avsc | 45 + .../test/avro/RepeatedTypeWithDefault.avsc | 45 + .../test/avro/RepeatedTypeWithDocFull.avsc | 96 ++ .../CrossRegionReplicationConverterTest.java | 362 +++++++ .../pom.xml | 164 --- .../CrossRegionReplicationMM2Converter.java | 161 --- ...ssRegionReplicationMM2ConverterConfig.java | 20 - .../resources/META-INF/maven/archetype.xml | 9 - .../resources/archetype-resources/pom.xml | 15 - .../src/main/java/App.java | 13 - .../src/test/java/AppTest.java | 38 - ...rossRegionReplicationMM2ConverterTest.java | 344 ------- pom.xml | 2 +- 26 files changed, 1974 insertions(+), 845 deletions(-) rename {cross-region-replication-mm2-converter => cross-region-replication-converter}/.gitignore (97%) create mode 100644 cross-region-replication-converter/pom.xml create mode 100644 cross-region-replication-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverter.java create mode 100644 cross-region-replication-converter/src/test/avro/AvroMessage.avsc create mode 100644 cross-region-replication-converter/src/test/avro/DocTestRecord.avsc create mode 100644 cross-region-replication-converter/src/test/avro/Enum.avsc create mode 100644 cross-region-replication-converter/src/test/avro/EnumUnion.avsc create mode 100644 cross-region-replication-converter/src/test/avro/MultiTypeUnionMessage.avsc create mode 100644 cross-region-replication-converter/src/test/avro/RepeatedTypeWithDefault.avsc create mode 100644 cross-region-replication-converter/src/test/avro/RepeatedTypeWithDocFull.avsc create mode 100644 cross-region-replication-converter/src/test/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverterTest.java delete mode 100644 cross-region-replication-mm2-converter/pom.xml delete mode 100644 cross-region-replication-mm2-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2Converter.java delete mode 100644 cross-region-replication-mm2-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2ConverterConfig.java delete mode 100644 cross-region-replication-mm2-converter/src/main/resources/META-INF/maven/archetype.xml delete mode 100644 cross-region-replication-mm2-converter/src/main/resources/archetype-resources/pom.xml delete mode 100644 cross-region-replication-mm2-converter/src/main/resources/archetype-resources/src/main/java/App.java delete mode 100644 cross-region-replication-mm2-converter/src/main/resources/archetype-resources/src/test/java/AppTest.java delete mode 100644 cross-region-replication-mm2-converter/src/test/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2ConverterTest.java diff --git a/.gitignore b/.gitignore index d9773299..ae8a3b4e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ **/*.project **/*.classpath **/*.factorypath -**/dependency-reduced-pom.xml \ No newline at end of file +**/dependency-reduced-pom.xml +/integration-tests/* \ No newline at end of file diff --git a/common/pom.xml b/common/pom.xml index 3b793447..e0bfec40 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -65,14 +65,6 @@ software.amazon.awssdk glue - - - org.apache.maven.plugins - maven-shade-plugin - 3.2.1 - maven-plugin - - ${parent.groupId} schema-registry-build-tools diff --git a/common/src/main/java/com/amazonaws/services/schemaregistry/common/AWSSchemaRegistryClient.java b/common/src/main/java/com/amazonaws/services/schemaregistry/common/AWSSchemaRegistryClient.java index b3b48af5..949e6563 100644 --- a/common/src/main/java/com/amazonaws/services/schemaregistry/common/AWSSchemaRegistryClient.java +++ b/common/src/main/java/com/amazonaws/services/schemaregistry/common/AWSSchemaRegistryClient.java @@ -90,19 +90,18 @@ public AWSSchemaRegistryClient(@NonNull AwsCredentialsProvider credentialsProvid .retryPolicy(retryPolicy) .addExecutionInterceptor(new UserAgentRequestInterceptor()) .build(); -// UrlConnectionHttpClient.Builder urlConnectionHttpClientBuilder = UrlConnectionHttpClient.builder(); -// if (glueSchemaRegistryConfiguration.getProxyUrl() != null) { -// log.debug("Creating http client using proxy {}", glueSchemaRegistryConfiguration.getProxyUrl().toString()); -// ProxyConfiguration proxy = ProxyConfiguration.builder().endpoint(glueSchemaRegistryConfiguration.getProxyUrl()).build(); -// urlConnectionHttpClientBuilder.proxyConfiguration(proxy); -// } + UrlConnectionHttpClient.Builder urlConnectionHttpClientBuilder = UrlConnectionHttpClient.builder(); + if (glueSchemaRegistryConfiguration.getProxyUrl() != null) { + log.debug("Creating http client using proxy {}", glueSchemaRegistryConfiguration.getProxyUrl().toString()); + ProxyConfiguration proxy = ProxyConfiguration.builder().endpoint(glueSchemaRegistryConfiguration.getProxyUrl()).build(); + urlConnectionHttpClientBuilder.proxyConfiguration(proxy); + } GlueClientBuilder glueClientBuilder = GlueClient .builder() .credentialsProvider(credentialsProvider) .overrideConfiguration(overrideConfiguration) -// .httpClient(urlConnectionHttpClientBuilder.build()) - .httpClient(UrlConnectionHttpClient.builder().build()) + .httpClient(urlConnectionHttpClientBuilder.build()) .region(Region.of(glueSchemaRegistryConfiguration.getRegion())); if (glueSchemaRegistryConfiguration.getEndPoint() != null) { diff --git a/common/src/main/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfiguration.java b/common/src/main/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfiguration.java index 80c215a7..cc40c6fc 100644 --- a/common/src/main/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfiguration.java +++ b/common/src/main/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfiguration.java @@ -27,6 +27,7 @@ import org.apache.commons.lang3.EnumUtils; import software.amazon.awssdk.services.glue.model.Compatibility; +import java.net.URI; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -43,10 +44,11 @@ public class GlueSchemaRegistryConfiguration { private AWSSchemaRegistryConstants.COMPRESSION compressionType = AWSSchemaRegistryConstants.COMPRESSION.NONE; private String endPoint; private String region; - private String srcEndPoint; - private String srcRegion; - private String tgtEndPoint; - private String tgtRegion; + //TODO: Remove configs that are not useful non replication use-cases. + private String sourceEndPoint; + private String sourceRegion; + private String targetEndPoint; + private String targetRegion; private long timeToLiveMillis = 24 * 60 * 60 * 1000L; private int cacheSize = 200; private AvroRecordType avroRecordType; @@ -58,6 +60,7 @@ public class GlueSchemaRegistryConfiguration { private Map tags = new HashMap<>(); private Map metadata; private String secondaryDeserializer; + private URI proxyUrl; /** * Name of the application using the serializer/deserializer. @@ -88,12 +91,12 @@ private void buildConfigs(Map configs) { } private void buildSchemaRegistryConfigs(Map configs) { - validateAndSetAWSSrcRegion(configs); - validateAndSetAWSSrcEndpoint(configs); - validateAndSetAWSTgtRegion(configs); - validateAndSetAWSTgtEndpoint(configs); validateAndSetAWSRegion(configs); + validateAndSetAWSSourceRegion(configs); + validateAndSetAWSTargetRegion(configs); validateAndSetAWSEndpoint(configs); + validateAndSetAWSSourceEndpoint(configs); + validateAndSetAWSTargetEndpoint(configs); validateAndSetRegistryName(configs); validateAndSetDescription(configs); validateAndSetAvroRecordType(configs); @@ -107,6 +110,7 @@ private void buildSchemaRegistryConfigs(Map configs) { validateAndSetMetadata(configs); validateAndSetUserAgent(configs); validateAndSetSecondaryDeserializer(configs); + validateAndSetProxyUrl(configs); } private void validateAndSetSecondaryDeserializer(Map configs) { @@ -145,7 +149,7 @@ private boolean validateCompressionType(String compressionType) { if (!EnumUtils.isValidEnum(AWSSchemaRegistryConstants.COMPRESSION.class, compressionType.toUpperCase())) { String errorMessage = String.format("Invalid Compression type : %s, Accepted values are : %s", compressionType, - AWSSchemaRegistryConstants.COMPRESSION.values()); + AWSSchemaRegistryConstants.COMPRESSION.values()); throw new AWSSchemaRegistryException(errorMessage); } return true; @@ -159,17 +163,17 @@ private void validateAndSetAWSRegion(Map configs) { } } - private void validateAndSetAWSSrcRegion(Map configs) { - if (isPresent(configs, AWSSchemaRegistryConstants.AWS_SRC_REGION)) { - this.srcRegion = String.valueOf(configs.get(AWSSchemaRegistryConstants.AWS_SRC_REGION)); + private void validateAndSetAWSSourceRegion(Map configs) { + if (isPresent(configs, AWSSchemaRegistryConstants.AWS_SOURCE_REGION)) { + this.sourceRegion = String.valueOf(configs.get(AWSSchemaRegistryConstants.AWS_SOURCE_REGION)); } } - private void validateAndSetAWSTgtRegion(Map configs) { - if (isPresent(configs, AWSSchemaRegistryConstants.AWS_TGT_REGION)) { - this.tgtRegion = String.valueOf(configs.get(AWSSchemaRegistryConstants.AWS_TGT_REGION)); + private void validateAndSetAWSTargetRegion(Map configs) { + if (isPresent(configs, AWSSchemaRegistryConstants.AWS_TARGET_REGION)) { + this.targetRegion = String.valueOf(configs.get(AWSSchemaRegistryConstants.AWS_TARGET_REGION)); } else { - this.tgtRegion = String.valueOf(configs.get(AWSSchemaRegistryConstants.AWS_REGION)); + this.targetRegion = String.valueOf(configs.get(AWSSchemaRegistryConstants.AWS_REGION)); } } @@ -180,10 +184,10 @@ private void validateAndSetCompatibility(Map configs) { .toUpperCase()); if (this.compatibilitySetting == null - || this.compatibilitySetting == Compatibility.UNKNOWN_TO_SDK_VERSION) { + || this.compatibilitySetting == Compatibility.UNKNOWN_TO_SDK_VERSION) { String errorMessage = String.format("Invalid compatibility setting : %s, Accepted values are : %s", - configs.get(AWSSchemaRegistryConstants.COMPATIBILITY_SETTING), - Compatibility.knownValues()); + configs.get(AWSSchemaRegistryConstants.COMPATIBILITY_SETTING), + Compatibility.knownValues()); throw new AWSSchemaRegistryException(errorMessage); } } else { @@ -205,20 +209,31 @@ private void validateAndSetAWSEndpoint(Map configs) { } } - private void validateAndSetAWSSrcEndpoint(Map configs) { - if (isPresent(configs, AWSSchemaRegistryConstants.AWS_SRC_ENDPOINT)) { - this.srcEndPoint = String.valueOf(configs.get(AWSSchemaRegistryConstants.AWS_SRC_ENDPOINT)); + private void validateAndSetAWSSourceEndpoint(Map configs) { + if (isPresent(configs, AWSSchemaRegistryConstants.AWS_SOURCE_ENDPOINT)) { + this.sourceEndPoint = String.valueOf(configs.get(AWSSchemaRegistryConstants.AWS_SOURCE_ENDPOINT)); } } - private void validateAndSetAWSTgtEndpoint(Map configs) { - if (isPresent(configs, AWSSchemaRegistryConstants.AWS_TGT_ENDPOINT)) { - this.tgtEndPoint = String.valueOf(configs.get(AWSSchemaRegistryConstants.AWS_TGT_ENDPOINT)); + private void validateAndSetAWSTargetEndpoint(Map configs) { + if (isPresent(configs, AWSSchemaRegistryConstants.AWS_TARGET_ENDPOINT)) { + this.targetEndPoint = String.valueOf(configs.get(AWSSchemaRegistryConstants.AWS_TARGET_ENDPOINT)); } else { - this.tgtEndPoint = String.valueOf(configs.get(AWSSchemaRegistryConstants.AWS_ENDPOINT)); + this.targetEndPoint = String.valueOf(configs.get(AWSSchemaRegistryConstants.AWS_ENDPOINT)); } } + private void validateAndSetProxyUrl(Map configs) { + if (isPresent(configs, AWSSchemaRegistryConstants.PROXY_URL)) { + String value = (String) configs.get(AWSSchemaRegistryConstants.PROXY_URL); + try { + this.proxyUrl = URI.create(value); + } catch (IllegalArgumentException e) { + String message = String.format("Proxy URL property is not a valid URL: %s", value); + throw new AWSSchemaRegistryException(message, e); + } + } + } private void validateAndSetDescription(Map configs) throws AWSSchemaRegistryException { if (isPresent(configs, AWSSchemaRegistryConstants.DESCRIPTION)) { @@ -278,7 +293,7 @@ private void validateAndSetSchemaAutoRegistrationSetting(Map configs) .toString()); } else { log.info("schemaAutoRegistrationEnabled is not defined in the properties. Using the default value {}", - schemaAutoRegistrationEnabled); + schemaAutoRegistrationEnabled); } } @@ -347,9 +362,9 @@ private boolean isPresent(Map configs, private Map getMapFromPropertiesFile(Properties properties) { return new HashMap<>(properties.entrySet() - .stream() - .collect(Collectors.toMap(e -> e.getKey() - .toString(), e -> e.getValue()))); + .stream() + .collect(Collectors.toMap(e -> e.getKey() + .toString(), e -> e.getValue()))); } private String buildDescriptionFromProperties() throws AWSSchemaRegistryException { diff --git a/common/src/main/java/com/amazonaws/services/schemaregistry/utils/AWSSchemaRegistryConstants.java b/common/src/main/java/com/amazonaws/services/schemaregistry/utils/AWSSchemaRegistryConstants.java index 6aaa0772..856f3b91 100644 --- a/common/src/main/java/com/amazonaws/services/schemaregistry/utils/AWSSchemaRegistryConstants.java +++ b/common/src/main/java/com/amazonaws/services/schemaregistry/utils/AWSSchemaRegistryConstants.java @@ -18,6 +18,10 @@ import software.amazon.awssdk.services.glue.model.Compatibility; public final class AWSSchemaRegistryConstants { + /** + * Proxy URL to use while connecting to AWS endpoint. + */ + public static final String PROXY_URL = "proxyUrl"; /** * AWS endpoint to use while initializing the client for service. */ @@ -29,20 +33,19 @@ public final class AWSSchemaRegistryConstants { /** * TODO: CR_GSR: AWS source endpoint to use while initializing the client for service. */ - public static final String AWS_SRC_ENDPOINT = "source.endpoint"; + public static final String AWS_SOURCE_ENDPOINT = "source.endpoint"; /** * TODO: CR_GSR: AWS source region to use while initializing the client for service. */ - public static final String AWS_SRC_REGION = "source.region"; + public static final String AWS_SOURCE_REGION = "source.region"; /** * AWS target endpoint to use while initializing the client for service. */ - public static final String AWS_TGT_ENDPOINT = "target.endpoint"; + public static final String AWS_TARGET_ENDPOINT = "target.endpoint"; /** * AWS target region to use while initializing the client for service. */ - public static final String AWS_TGT_REGION = "target.region"; - + public static final String AWS_TARGET_REGION = "target.region"; /** * Header Version Byte. */ @@ -229,4 +232,4 @@ public enum COMPRESSION { */ ZLIB } -} \ No newline at end of file +} diff --git a/common/src/test/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfigurationTest.java b/common/src/test/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfigurationTest.java index 90a3c581..9ae2bb09 100644 --- a/common/src/test/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfigurationTest.java +++ b/common/src/test/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfigurationTest.java @@ -257,8 +257,8 @@ public void testBuildConfig_invalidCompressionType_throwsException() { @Test public void testBuildConfig_validTags_succeeds() { Map testTags = new HashMap<>(); - testTags.put("testTagKey", "testTagValue"); - testTags.put("testTagKey2", "testTagValue2"); + testTags.put("testTagKey","testTagValue"); + testTags.put("testTagKey2","testTagValue2"); configs.put(AWSSchemaRegistryConstants.TAGS, testTags); GlueSchemaRegistryConfiguration serDeConfigs = new GlueSchemaRegistryConfiguration(configs); @@ -278,8 +278,8 @@ public void testBuildConfig_validTags_succeeds() { public void testBuildConfigWithProperties_validTags_succeeds() { Properties props = createTestProperties(); HashMap testTags = new HashMap<>(); - testTags.put("testTagKey", "testTagValue"); - testTags.put("testTagKey2", "testTagValue2"); + testTags.put("testTagKey","testTagValue"); + testTags.put("testTagKey2","testTagValue2"); props.put(AWSSchemaRegistryConstants.TAGS, testTags); GlueSchemaRegistryConfiguration serDeConfigs = new GlueSchemaRegistryConfiguration(props); @@ -370,28 +370,27 @@ public void testValidateAndSetRegistryName_withRegistryConfig_throwsException() assertEquals(expectedRegistryName, glueSchemaRegistryConfiguration.getRegistryName()); } + /** + * Tests valid proxy URL value. + */ + @Test + public void testBuildConfig_validProxyUrl_success() { + Properties props = createTestProperties(); + String proxy = "http://proxy.servers.url:8080"; + props.put(AWSSchemaRegistryConstants.PROXY_URL, proxy); + GlueSchemaRegistryConfiguration glueSchemaRegistryConfiguration = new GlueSchemaRegistryConfiguration(props); + assertEquals(URI.create(proxy), glueSchemaRegistryConfiguration.getProxyUrl()); + } -// /** -// * Tests valid proxy URL value. -// */ -// @Test -// public void testBuildConfig_validProxyUrl_success() { -// Properties props = createTestProperties(); -// String proxy = "http://proxy.servers.url:8080"; -// props.put(AWSSchemaRegistryConstants.PROXY_URL, proxy); -// GlueSchemaRegistryConfiguration glueSchemaRegistryConfiguration = new GlueSchemaRegistryConfiguration(props); -// assertEquals(URI.create(proxy), glueSchemaRegistryConfiguration.getProxyUrl()); -// } -// -// /** -// * Tests invalid proxy URL value. -// */ -// @Test -// public void testBuildConfig_invalidProxyUrl_throwsException() { -// Properties props = createTestProperties(); -// String proxy = "http:// proxy.url: 8080"; -// props.put(AWSSchemaRegistryConstants.PROXY_URL, "http:// proxy.url: 8080"); -// Exception exception = assertThrows(AWSSchemaRegistryException.class, () -> new GlueSchemaRegistryConfiguration(props)); -// assertEquals("Proxy URL property is not a valid URL: "+proxy, exception.getMessage()); -// } + /** + * Tests invalid proxy URL value. + */ + @Test + public void testBuildConfig_invalidProxyUrl_throwsException() { + Properties props = createTestProperties(); + String proxy = "http:// proxy.url: 8080"; + props.put(AWSSchemaRegistryConstants.PROXY_URL, "http:// proxy.url: 8080"); + Exception exception = assertThrows(AWSSchemaRegistryException.class, () -> new GlueSchemaRegistryConfiguration(props)); + assertEquals("Proxy URL property is not a valid URL: "+proxy, exception.getMessage()); + } } \ No newline at end of file diff --git a/cross-region-replication-mm2-converter/.gitignore b/cross-region-replication-converter/.gitignore similarity index 97% rename from cross-region-replication-mm2-converter/.gitignore rename to cross-region-replication-converter/.gitignore index 5ff6309b..8a498597 100644 --- a/cross-region-replication-mm2-converter/.gitignore +++ b/cross-region-replication-converter/.gitignore @@ -3,6 +3,8 @@ target/ !**/src/main/**/target/ !**/src/test/**/target/ +resources/ + ### IntelliJ IDEA ### .idea/modules.xml .idea/jarRepositories.xml diff --git a/cross-region-replication-converter/pom.xml b/cross-region-replication-converter/pom.xml new file mode 100644 index 00000000..2ddf9798 --- /dev/null +++ b/cross-region-replication-converter/pom.xml @@ -0,0 +1,229 @@ + + + 4.0.0 + + ${parent.groupId} + schema-registry-cross-region-kafkaconnect-converter + ${parent.version} + AWS Cross Region Glue Schema Registry Kafka Connect Schema Replication Converter + The AWS Glue Schema Registry Kafka Connect Converter enables Java developers to easily replicate + schemas across different AWS Glue Schema Registries + + https://aws.amazon.com/glue + jar + + + software.amazon.glue + schema-registry-parent + 1.1.15 + ../pom.xml + + + + + Apache License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + ossrh + https://aws.oss.sonatype.org/content/repositories/snapshots + + + ossrh + https://aws.oss.sonatype.org/service/local/staging/deploy/maven2/ + + + + scm:git:https://github.com/aws/aws-glue-schema-registry.git + scm:git:git@github.com:aws/aws-glue-schema-registry.git + https://github.com/awslabs/aws-glue-schema-registry.git + + + + + ${parent.groupId} + schema-registry-serde + ${parent.version} + + + + org.apache.maven.plugins + maven-shade-plugin + 3.2.1 + maven-plugin + + + + org.apache.kafka + connect-api + + + org.projectlombok + lombok + + + org.mockito + mockito-core + test + + + org.mockito + mockito-junit-jupiter + test + + + org.junit.vintage + junit-vintage-engine + 5.7.0 + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.junit.jupiter + junit-jupiter-params + test + + + org.junit.platform + junit-platform-commons + test + + + org.junit.jupiter + junit-jupiter-api + test + + + junit + junit + test + + + org.powermock + powermock-reflect + 2.0.7 + test + + + uk.co.jemos.podam + podam + 7.2.5.RELEASE + test + + + software.amazon.glue + schema-registry-kafkaconnect-converter + 1.1.15 + compile + + + + + + + maven-shade-plugin + 3.2.1 + + + + + package + + shade + + + + + + org.apache.avro + avro-maven-plugin + ${avro.version} + + + generate-test-sources + test-sources + + schema + + + + generate-sources + sources + + schema + + + + + true + String + + + + org.apache.maven.plugins + maven-compiler-plugin + + 15 + 15 + + + + + + + publishing + + + + org.apache.maven.plugins + maven-gpg-plugin + 1.6 + + + sign-artifacts + verify + + sign + + + + + + org.sonatype.plugins + nexus-staging-maven-plugin + 1.6.8 + true + + sonatype-nexus-staging + https://aws.oss.sonatype.org + false + + + + + + + \ No newline at end of file diff --git a/cross-region-replication-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverter.java b/cross-region-replication-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverter.java new file mode 100644 index 00000000..a15ad0ce --- /dev/null +++ b/cross-region-replication-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverter.java @@ -0,0 +1,103 @@ +package com.amazonaws.services.crossregion.schemaregistry.kafkaconnect; + +import com.amazonaws.services.schemaregistry.common.Schema; +import com.amazonaws.services.schemaregistry.common.configs.GlueSchemaRegistryConfiguration; +import com.amazonaws.services.schemaregistry.deserializers.GlueSchemaRegistryDeserializerImpl; +import com.amazonaws.services.schemaregistry.exception.AWSSchemaRegistryException; +import com.amazonaws.services.schemaregistry.serializers.GlueSchemaRegistrySerializerImpl; +import com.amazonaws.services.schemaregistry.utils.AWSSchemaRegistryConstants; + +import lombok.Data; + +import org.apache.kafka.common.errors.SerializationException; +import org.apache.kafka.connect.data.*; +import org.apache.kafka.connect.errors.DataException; +import org.apache.kafka.connect.storage.Converter; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; + +import java.util.HashMap; +import java.util.Map; + +@Data +public class CrossRegionReplicationConverter implements Converter { + + private AwsCredentialsProvider credentialsProvider; + private GlueSchemaRegistryDeserializerImpl deserializer; + private GlueSchemaRegistrySerializerImpl serializer; + private boolean isKey; + + /** + * Constructor used by Kafka Connect user. + */ + public CrossRegionReplicationConverter(){}; + + /** + * Constructor accepting AWSCredentialsProvider. + * + * @param credentialsProvider AWSCredentialsProvider instance. + */ + public CrossRegionReplicationConverter( + AwsCredentialsProvider credentialsProvider, + GlueSchemaRegistryDeserializerImpl deserializerImpl, + GlueSchemaRegistrySerializerImpl serializerImpl) { + + this.credentialsProvider = credentialsProvider; + this.deserializer = deserializerImpl; + this.serializer = serializerImpl; + } + + /** + * Configure the Schema Replication Converter. + * @param configs configuration elements for the converter + * @param isKey true if key, false otherwise + */ + @Override + public void configure(Map configs, boolean isKey) { + this.isKey = isKey; + //TODO: Support credentialProvider passed on by the user + credentialsProvider = DefaultCredentialsProvider.builder().build(); + + // Put the source and target regions into configurations respectively + Map sourceConfigs = new HashMap<>(configs); + Map targetConfigs = new HashMap<>(configs); + + if (configs.get(AWSSchemaRegistryConstants.AWS_SOURCE_REGION) == null){ + throw new DataException("Source Region is not provided."); + } + else if (configs.get(AWSSchemaRegistryConstants.AWS_TARGET_REGION) == null && configs.get(AWSSchemaRegistryConstants.AWS_REGION) == null){ + throw new DataException("Target Region is not provided."); + } + + sourceConfigs.put(AWSSchemaRegistryConstants.AWS_REGION, configs.get(AWSSchemaRegistryConstants.AWS_SOURCE_REGION)); + targetConfigs.put(AWSSchemaRegistryConstants.AWS_REGION, configs.get(AWSSchemaRegistryConstants.AWS_TARGET_REGION)); + + serializer = new GlueSchemaRegistrySerializerImpl(credentialsProvider, new GlueSchemaRegistryConfiguration(targetConfigs)); + deserializer = new GlueSchemaRegistryDeserializerImpl(credentialsProvider, new GlueSchemaRegistryConfiguration(sourceConfigs)); + } + + @Override + public byte[] fromConnectData(String topic, org.apache.kafka.connect.data.Schema schema, Object value) { + if (value == null) return null; + byte[] bytes = (byte[]) value; + + try { + byte[] deserializedBytes = deserializer.getData(bytes); + Schema deserializedSchema = deserializer.getSchema(bytes); + //The registry is decided by the configuration in the target region , schema name is the same as the source region + return serializer.encode(topic, deserializedSchema, deserializedBytes); + + } catch (SerializationException | AWSSchemaRegistryException e){ + throw new DataException("Converting Kafka Connect data to byte[] failed due to serialization/deserialization error: ", e); + } + } + + /** + * This method is not intended to be used for the CrossRegionReplicationConverter given it is integrated with a source connector + * + */ + @Override + public SchemaAndValue toConnectData(String topic, byte[] value) { + throw new UnsupportedOperationException("This method is not supported"); + } +} \ No newline at end of file diff --git a/cross-region-replication-converter/src/test/avro/AvroMessage.avsc b/cross-region-replication-converter/src/test/avro/AvroMessage.avsc new file mode 100644 index 00000000..4a784cd6 --- /dev/null +++ b/cross-region-replication-converter/src/test/avro/AvroMessage.avsc @@ -0,0 +1,944 @@ +{ + "type" : "record", + "name" : "AvroMessage", + "namespace" : "io.test.avro.core", + "fields" : [ { + "name" : "payload", + "type" : [ "null", { + "type" : "record", + "name" : "Event", + "namespace" : "io.test.trade.v1.order", + "fields" : [ { + "name" : "state", + "type" : { + "type" : "record", + "name" : "State", + "fields" : [ { + "name" : "orderId", + "type" : { + "type" : "record", + "name" : "Id", + "namespace" : "io.test.trade.v1", + "doc" : "Id of an order or position.", + "fields" : [ { + "name" : "source", + "type" : { + "type" : "enum", + "name" : "Source", + "symbols" : [ "ORDER_SERVER", "CLIENT", "UNIVERSE", "L2", "L2_CHAIN", "EXCHANGE", "UNIVERSE_ATTR", "UNDEFINED" ] + } + }, { + "name" : "value", + "type" : { + "type" : "string", + "avro.java.string" : "String" + } + } ] + }, + "doc" : "The ID to reference this order." + }, { + "name" : "accountId", + "type" : { + "type" : "record", + "name" : "Id", + "namespace" : "io.test.trade.v1.common.account", + "fields" : [ { + "name" : "value", + "type" : { + "type" : "string", + "avro.java.string" : "String" + } + } ] + }, + "doc" : "The account that the order is associated to." + }, { + "name" : "allocation", + "type" : { + "type" : "record", + "name" : "Allocation", + "namespace" : "io.test.trade.v1.common", + "fields" : [ { + "name" : "direction", + "type" : { + "type" : "enum", + "name" : "Direction", + "symbols" : [ "BUY", "SELL" ] + } + }, { + "name" : "size", + "type" : { + "type" : "record", + "name" : "Size", + "fields" : [ { + "name" : "value", + "type" : "double" + } ] + } + }, { + "name" : "displaySize", + "type" : "Size", + "doc" : "Size used for presentation and external reporting purposes. Note: Margining, Profit/Loss calculation, exposure, etc., should multiply this size with lotSize for calculations." + }, { + "name" : "displaySizeUnit", + "type" : { + "type" : "enum", + "name" : "DisplaySizeUnit", + "symbols" : [ "SHARES", "CONTRACTS", "AMOUNT_PER_POINTS" ] + }, + "doc" : "Define how the displaySize is expressed." + }, { + "name" : "lotSize", + "type" : "double", + "doc" : "Defined on the instrument. Clients on spread-bet accounts use lot size of 1, while CFD and StockBroking clients use lot size configured on the instrument. Dealers can book orders on any client account with a lot size of 1. Hedge accounts have different rules - they are generally booked in lots with lotSize 1, except equities (and equity options)" + }, { + "name" : "currency", + "type" : { + "type" : "record", + "name" : "ISOCurrency", + "fields" : [ { + "name" : "value", + "type" : { + "type" : "string", + "avro.java.string" : "String" + } + } ] + }, + "doc" : "Currency of the order/position" + } ] + }, + "doc" : "Details of the allocation of this order, size/amount, currency and direction." + }, { + "name" : "instrument", + "type" : { + "type" : "record", + "name" : "Instrument", + "namespace" : "io.test.trade.v1.common", + "fields" : [ { + "name" : "bookingCodeType", + "type" : { + "type" : "enum", + "name" : "BookingCodeType", + "symbols" : [ "EPIC", "ISIN_AND_CURRENCY" ] + }, + "doc" : "Indicates if the booking was made using an ISIN or EPIC. If EPIC, unique instrument identifier is the EPIC. If ISIN_AND_CURRENCY, the unique identifier is ISIN and CURRENCY. In this last case EPIC is also set for internal usage." + }, { + "name" : "epic", + "type" : { + "type" : "string", + "avro.java.string" : "String" + }, + "doc" : "This field is populated for all booking operations and it represents the id of the instrument on which the booking was made. Note: This field will probably be made optional when contract-ids are introduced" + }, { + "name" : "isin", + "type" : [ "null", { + "type" : "string", + "avro.java.string" : "String" + } ], + "doc" : "ISIN is populated for stock-broking deals", + "default" : null + }, { + "name" : "level", + "type" : { + "type" : "record", + "name" : "Level", + "fields" : [ { + "name" : "value", + "type" : "double" + } ] + }, + "doc" : "The level at which the position is booked. This level is used for pnl, margining, auto-hedge, exposure calculation, and other such purposes. This level should always be displayLevel multiplied by scaling factor, however, there are a few UV based flows where that constraint doesn't hold." + }, { + "name" : "displayLevel", + "type" : "Level", + "doc" : "The level displayed in the front-end. Note: PnL calculation, auto-hedge and other such operations should multiply by scaling factor." + }, { + "name" : "scalingFactor", + "type" : "double", + "doc" : "The scaling factor used for booking this position." + }, { + "name" : "instrumentType", + "type" : [ "null", { + "type" : "enum", + "name" : "InstrumentType", + "symbols" : [ "SHARES", "CURRENCIES", "INDICES", "BINARY", "FAST_BINARY", "COMMODITIES", "RATES", "OPTIONS_SHARES", "OPTIONS_CURRENCIES", "OPTIONS_INDICES", "OPTIONS_COMMODITIES", "OPTIONS_RATES", "BUNGEE_SHARES", "BUNGEE_CURRENCIES", "BUNGEE_INDICES", "BUNGEE_COMMODITIES", "BUNGEE_RATES", "CAPPED_BUNGEE", "TEST_MARKETS", "SPORTS", "SECTORS" ] + } ], + "doc" : "Type of the instrument on which the booking is made. This field is made optional to accommodate positions missing instrument type for some reason (very old positions, UV based legacy flows, etc)", + "default" : null + }, { + "name" : "marketName", + "type" : [ "null", { + "type" : "string", + "avro.java.string" : "String" + } ], + "default" : null + }, { + "name" : "scalingFactorOnInstrumentIfDifferent", + "type" : [ "null", "double" ], + "doc" : "If the scaling factor on the instrument is different from the scaling factor used to book this position then this field carries this new scaling factor. This field is used by a trade anomaly report (maintained by XCON)", + "default" : null + } ] + }, + "doc" : "Details on booked level and instrument information" + }, { + "name" : "timestamps", + "type" : { + "type" : "record", + "name" : "Timestamps", + "namespace" : "io.test.trade.v1.common", + "doc" : "See http://test.io/wiki/Position+History+Tactical+Fixes", + "fields" : [ { + "name" : "created", + "type" : { + "type" : "record", + "name" : "UTCTimestamp", + "fields" : [ { + "name" : "value", + "type" : "long" + } ] + }, + "doc" : "Timestamp of the trade's creation time" + }, { + "name" : "lastModified", + "type" : [ "null", "UTCTimestamp" ], + "doc" : "Timestamp of the trade's modification time. For RESTATE, this field indicates the timestamp of the restate", + "default" : null + }, { + "name" : "lastEdited", + "type" : [ "null", "UTCTimestamp" ], + "doc" : "Applicable only for RESTATES and specifies the timestamp at which this trade was last edited", + "default" : null + }, { + "name" : "margin", + "type" : [ "null", "UTCTimestamp" ], + "doc" : "Timestamp of the trade's margin time.", + "default" : null + } ] + }, + "doc" : "Timestamps of when the order was created, modified or last edited." + }, { + "name" : "isForceOpen", + "type" : "boolean", + "doc" : "Upon full fill, should this order close positions existing in opposite direction?", + "default" : false + }, { + "name" : "attachedStop", + "type" : [ "null", { + "type" : "record", + "name" : "Stop", + "namespace" : "io.test.trade.v1.common.contingent", + "fields" : [ { + "name" : "value", + "type" : "double", + "doc" : "Stop value can be expressed either as a Level or Distance. Use this field in conjunction with valueType" + }, { + "name" : "valueType", + "type" : { + "type" : "enum", + "name" : "StopValueType", + "symbols" : [ "DISTANCE", "LEVEL" ] + }, + "doc" : "Represents the unit in which the stop value is expressed" + }, { + "name" : "isGuaranteed", + "type" : "boolean", + "default" : false + }, { + "name" : "lrPremium", + "type" : [ "null", "double" ], + "doc" : "This field represents a multiplier to be applied to the trade's size to derive a limited risk fee (LR Fee). The LR fee is a monetary amount and is expressed in the currency of the order.", + "default" : null + }, { + "name" : "trailingStop", + "type" : [ "null", { + "type" : "record", + "name" : "TrailingStop", + "fields" : [ { + "name" : "distance", + "type" : "double", + "default" : 0.0 + }, { + "name" : "increment", + "type" : "double", + "default" : 0.0 + } ] + } ], + "default" : null + }, { + "name" : "orderIds", + "type" : [ "null", { + "type" : "array", + "items" : "io.test.trade.v1.Id" + } ], + "doc" : "Ids identifying this stop.", + "default" : null + } ] + } ], + "doc" : "An attached Stop is a 'stop-loss' order; an instruction to close a position when a certain level is breached, to minimize loss.", + "default" : null + }, { + "name" : "attachedLimit", + "type" : [ "null", { + "type" : "record", + "name" : "Limit", + "namespace" : "io.test.trade.v1.common.contingent", + "fields" : [ { + "name" : "value", + "type" : "double", + "doc" : "Limit value can be expressed either as a Level or Distance. Use this field in conjunction with valueType" + }, { + "name" : "valueType", + "type" : { + "type" : "enum", + "name" : "LimitValueType", + "symbols" : [ "DISTANCE", "LEVEL" ] + }, + "doc" : "Represents the unit in which the limit value is expressed" + }, { + "name" : "orderIds", + "type" : [ "null", { + "type" : "array", + "items" : "io.test.trade.v1.Id" + } ], + "doc" : "Ids identifying this limit.", + "default" : null + } ] + } ], + "doc" : "An attached limit is a 'profit-limit' order; an instruction to close a position when a certain level is breached, to guarantee profit.", + "default" : null + }, { + "name" : "legacyInfo", + "type" : { + "type" : "record", + "name" : "LegacyInfo", + "namespace" : "io.test.trade.v1.common", + "doc" : "Legacy information for retro compatibility purpose. Should not be used in any new service.", + "fields" : [ { + "name" : "uvCurrency", + "type" : { + "type" : "string", + "avro.java.string" : "String" + }, + "doc" : "deprecated field", + "default" : "" + }, { + "name" : "marketCommodity", + "type" : { + "type" : "string", + "avro.java.string" : "String" + }, + "doc" : "deprecated field", + "default" : "" + }, { + "name" : "prompt", + "type" : [ "null", { + "type" : "string", + "avro.java.string" : "String" + } ], + "doc" : "Deprecated field. Represents the period at which the instrument expires. This field is also referred to as 'period' in some legacy messages", + "default" : null + }, { + "name" : "submitOrderType", + "type" : [ "null", { + "type" : "enum", + "name" : "SubmitOrderType", + "symbols" : [ "UNCONTROLLED_OPEN", "UNCONTROLLED_CLOSE", "CONTROLLED_OPEN", "CONTROLLED_CLOSE", "UNCONTROLLED_OPEN_WITH_STOP", "UNCONTROLLED_CLOSE_WITH_STOP", "MANUAL_POSITION_DELETE", "OPEN_WITH_EXPIRY_STOP" ] + } ], + "doc" : "deprecated field", + "default" : null + }, { + "name" : "requestType", + "type" : [ "null", { + "type" : "enum", + "name" : "RequestType", + "symbols" : [ "AMEND_ORDER", "FINANCE_ORDER", "CFD_ORDER", "PHYSICAL", "UNATTACHED_LIMIT_ORDER", "UNATTACHED_STOP_ORDER", "UNATTACHED_ORDER_DELETE", "UNATTACHED_ORDER_FILL", "UNATTACHED_BUFFER_LIMITS", "UNATTACHED_BUFFER_LIMITS_DELETE", "MARKET_ORDER" ] + } ], + "doc" : "deprecated field", + "default" : null + }, { + "name" : "exchangeRateEpic", + "type" : [ "null", { + "type" : "string", + "avro.java.string" : "String" + } ], + "doc" : "Deprecated field. This field represents the fx rate epic mapped to the trade's currency.", + "default" : null + } ] + }, + "doc" : "Legacy attributes, a hang over from UV, eg market commod." + }, { + "name" : "channel", + "type" : { + "type" : "record", + "name" : "Channel", + "namespace" : "io.test.trade.v1.common", + "fields" : [ { + "name" : "value", + "type" : { + "type" : "string", + "avro.java.string" : "String" + } + } ] + }, + "doc" : "The channel though which the order was placed , eg. WEB, L2. This will not change if the order is amended. For this, see channel in change info." + }, { + "name" : "expiry", + "type" : { + "type" : "record", + "name" : "Expiry", + "namespace" : "io.test.trade.v1.order.common", + "fields" : [ { + "name" : "timeInForce", + "type" : { + "type" : "enum", + "name" : "TimeInForce", + "namespace" : "io.test.trade.v1.common", + "symbols" : [ "DAY", "GOOD_TILL_CANCEL", "AT_THE_OPENING", "IMMEDIATE_OR_CANCEL", "FILL_OR_KILL", "GOOD_TILL_CROSSING", "GOOD_TILL_DATE", "AT_THE_CLOSE", "DAY_ALL_SESSIONS" ] + } + }, { + "name" : "goodTillDateTimestamp", + "type" : [ "null", "io.test.trade.v1.common.UTCTimestamp" ], + "default" : null + } ] + }, + "doc" : "The date/time of when this order expires." + }, { + "name" : "accountAttributes", + "type" : { + "type" : "record", + "name" : "Attributes", + "namespace" : "io.test.trade.v1.common.account", + "fields" : [ { + "name" : "accountProduct", + "type" : [ "null", { + "type" : "enum", + "name" : "Product", + "symbols" : [ "SPREAD_BET", "CFD", "PHYSICAL" ] + } ], + "default" : null + }, { + "name" : "locale", + "type" : { + "type" : "string", + "avro.java.string" : "String" + }, + "doc" : "Represents the locale of the account, such as en_gb. It is highly likely that this field will be removed in the future." + }, { + "name" : "powerOfAttorneyName", + "type" : [ "null", { + "type" : "string", + "avro.java.string" : "String" + } ], + "doc" : "The POA name specified on the order that booked this position.", + "default" : null + }, { + "name" : "convertOnCloseCurrency", + "type" : [ "null", "io.test.trade.v1.common.ISOCurrency" ], + "doc" : "Retrieved from the convert on close information stored on this position. Note: Only populated if convert-on-close is applicable for this position.", + "default" : null + }, { + "name" : "currency", + "type" : [ "null", "io.test.trade.v1.common.ISOCurrency" ], + "doc" : "Account's currency in ISO format", + "default" : null + } ] + }, + "doc" : "Account attributes such as convert on close details and Power Of Attorney name." + }, { + "name" : "dmaOrder", + "type" : [ "null", { + "type" : "record", + "name" : "Order", + "namespace" : "io.test.trade.v1.common.dma", + "fields" : [ { + "name" : "pseudoPositionId", + "type" : [ "null", "io.test.trade.v1.Id" ], + "doc" : "Represents ID of position created by a partially filled DMA order.", + "default" : null + }, { + "name" : "orderType", + "type" : { + "type" : "enum", + "name" : "OrderType", + "symbols" : [ "MARKET", "LIMIT", "STOP", "STOP_LIMIT", "MARKET_ON_CLOSE", "WITH_OR_WITHOUT", "LIMIT_OR_BETTER", "LIMIT_WITH_OR_WITHOUT", "ON_BASIS", "ON_CLOSE", "LIMIT_ON_CLOSE", "FOREX_MARKET", "PREVIOUSLY_QUOTED", "PREVIOUSLY_INDICATED", "FOREX_LIMIT", "PEGGED", "TRADE_REPORT", "FAST_BINARY", "UNKNOWN" ] + } + }, { + "name" : "timeInForce", + "type" : "io.test.trade.v1.common.TimeInForce" + }, { + "name" : "originalSize", + "type" : "io.test.trade.v1.common.Size", + "doc" : "The original size on a DMA working order. This is in display terms and does not include lotSize" + }, { + "name" : "fills", + "type" : [ "null", { + "type" : "record", + "name" : "Fills", + "fields" : [ { + "name" : "aggregatedFill", + "type" : [ "null", { + "type" : "array", + "items" : { + "type" : "record", + "name" : "AggregatedFill", + "doc" : "Aggregated information of fills received per hedge account", + "fields" : [ { + "name" : "hedgeAccountId", + "type" : "io.test.trade.v1.common.account.Id" + }, { + "name" : "averageLevel", + "type" : "io.test.trade.v1.common.Level", + "doc" : "A volume-weighted-average level of all fills originating from this hedge account" + }, { + "name" : "totalSize", + "type" : "io.test.trade.v1.common.Size", + "doc" : "Total size of all fills received from this hedge account" + }, { + "name" : "averageExchangeFee", + "type" : "double", + "doc" : "The fee is expressed in account's currency." + } ] + } + } ], + "doc" : "A collection of DMA fills aggregated per hedge account", + "default" : null + }, { + "name" : "updateType", + "type" : { + "type" : "enum", + "name" : "FillsUpdateType", + "symbols" : [ "ADD", "COPY", "DELETE_ALL" ] + }, + "default" : "COPY" + }, { + "name" : "nextWorkingOrderId", + "type" : [ "null", "io.test.trade.v1.Id" ], + "default" : null + } ] + } ], + "default" : null + }, { + "name" : "isDMAInteractable", + "type" : "boolean", + "default" : true + }, { + "name" : "executionPricePreference", + "type" : [ "null", { + "type" : "enum", + "name" : "ExecType", + "symbols" : [ "ASK", "BID" ] + } ], + "doc" : "Called executionInstruction in current schema. This field represents DMA FX Stop Order execution price preference, could be either empty, ASK(0) or BID(9) and indicates whether one's order gets executed closer to the Bid or Ask side compared to the specified order direction.", + "default" : null + }, { + "name" : "uvOrderId", + "type" : [ "null", "io.test.trade.v1.Id" ], + "default" : null + }, { + "name" : "isPseudoPosition", + "type" : "boolean", + "doc" : "Is this position a partial fill for a DMA order?", + "default" : false + }, { + "name" : "nextPseudoPositionId", + "type" : [ "null", "io.test.trade.v1.Id" ], + "doc" : "In a DMA amend scenario, the id of a pseudo-position changes and this field indicates the new pseudo position id.", + "default" : null + } ] + } ], + "doc" : "DMA order attributes such as order type", + "default" : null + }, { + "name" : "additionalIds", + "type" : [ "null", { + "type" : "array", + "items" : "io.test.trade.v1.Id" + } ], + "doc" : "Additional ids used to reference this order.", + "default" : null + }, { + "name" : "stockBrokingAttributes", + "type" : [ "null", { + "type" : "record", + "name" : "Attributes", + "namespace" : "io.test.trade.v1.order.stockbroking", + "fields" : [ { + "name" : "settlementDate", + "type" : [ "null", "io.test.trade.v1.common.UTCTimestamp" ], + "default" : null + }, { + "name" : "tradeDate", + "type" : [ "null", "io.test.trade.v1.common.UTCTimestamp" ], + "default" : null + }, { + "name" : "charges", + "type" : [ "null", { + "type" : "record", + "name" : "Charges", + "fields" : [ { + "name" : "commission", + "type" : { + "type" : "record", + "name" : "Money", + "namespace" : "io.test.trade.v1.common", + "fields" : [ { + "name" : "currency", + "type" : "ISOCurrency" + }, { + "name" : "amount", + "type" : "double" + } ] + } + }, { + "name" : "physicalCharges", + "type" : [ "null", { + "type" : "array", + "items" : { + "type" : "record", + "name" : "Charge", + "fields" : [ { + "name" : "code", + "type" : { + "type" : "string", + "avro.java.string" : "String" + }, + "default" : "" + }, { + "name" : "name", + "type" : { + "type" : "string", + "avro.java.string" : "String" + }, + "default" : "" + }, { + "name" : "amount", + "type" : "io.test.trade.v1.common.Money" + }, { + "name" : "rate", + "type" : "double" + }, { + "name" : "threshold", + "type" : "double" + } ] + } + } ], + "default" : null + } ] + } ], + "default" : null + }, { + "name" : "reservedCash", + "type" : [ "null", "io.test.trade.v1.common.Money" ], + "default" : null + } ] + } ], + "doc" : "Stock Broking specific attributes such as settlement date and trade date.", + "default" : null + }, { + "name" : "commissionInstructions", + "type" : [ "null", { + "type" : "record", + "name" : "Instructions", + "namespace" : "io.test.trade.v1.common.commission", + "fields" : [ { + "name" : "bypasses", + "type" : [ "null", { + "type" : "record", + "name" : "Bypasses", + "fields" : [ { + "name" : "legacyCRPremium", + "type" : "boolean", + "doc" : "For guaranteed stops, should bypass reserving LR Premium fee", + "default" : false + }, { + "name" : "commission", + "type" : "boolean", + "doc" : "Should commission be bypassed", + "default" : false + }, { + "name" : "charges", + "type" : "boolean", + "doc" : "Should charges be bypassed", + "default" : false + }, { + "name" : "consideration", + "type" : "boolean", + "doc" : "Should consideration based fee be bypassed", + "default" : false + } ] + } ], + "default" : null + }, { + "name" : "overrideType", + "type" : [ "null", { + "type" : "enum", + "name" : "OverrideType", + "doc" : "AMOUNT: This type used when dealer wants to fixed Commission charge in Client's base currency. When the Amount value is Zero, no commission will be charged.\n. WEB_RATES: This type is used when dealer wants the client's web rates to be used. Otherwise an input from IG Dealer will cause the phone rates to be used.\nPERCENT: This type is used when dealer wants to supply the percentage rate to be used for commission calculation", + "symbols" : [ "AMOUNT", "PERCENT", "WEB_RATES" ] + } ], + "default" : null + }, { + "name" : "rate", + "type" : [ "null", "double" ], + "default" : null + }, { + "name" : "comment", + "type" : [ "null", { + "type" : "string", + "avro.java.string" : "String" + } ], + "default" : null + } ] + } ], + "doc" : "instructions of which changes to bypass or override.", + "default" : null + }, { + "name" : "additionalLeg", + "type" : [ "null", { + "type" : "record", + "name" : "AdditionalLeg", + "namespace" : "io.test.trade.v1.order.common", + "fields" : [ { + "name" : "instrument", + "type" : "io.test.trade.v1.common.Instrument" + }, { + "name" : "marketCommodity", + "type" : [ "null", { + "type" : "string", + "avro.java.string" : "String" + } ], + "default" : null + }, { + "name" : "direction", + "type" : "io.test.trade.v1.common.Direction" + }, { + "name" : "averagePrice", + "type" : [ "null", "io.test.trade.v1.common.Level" ], + "default" : null + } ] + } ], + "doc" : "DMA orders on hedge accounts can optionally have an additional leg (instrument) to book the same order.", + "default" : null + }, { + "name" : "profileData", + "type" : [ "null", { + "type" : "record", + "name" : "ProfileData", + "fields" : [ { + "name" : "parentAccountId", + "type" : "io.test.trade.v1.common.account.Id", + "doc" : "For Profile orders this is the reference to the parent account." + }, { + "name" : "parentOrderId", + "type" : "io.test.trade.v1.Id", + "doc" : "For profile orders this will be the parent order id." + } ] + } ], + "doc" : "Profile orders where the order is processed on the parent account and booked against the child accounts.", + "default" : null + }, { + "name" : "lockState", + "type" : [ "null", { + "type" : "record", + "name" : "State", + "namespace" : "io.test.trade.v1.common.lock", + "fields" : [ { + "name" : "idOfLockingDMAOrder", + "type" : "io.test.trade.v1.Id", + "doc" : "Contains id of a DMA order that has locked this position, presumably for explicitly closing this position" + }, { + "name" : "holder", + "type" : [ "null", { + "type" : "string", + "avro.java.string" : "String" + } ], + "default" : null + }, { + "name" : "source", + "type" : [ "null", { + "type" : "enum", + "name" : "Source", + "symbols" : [ "COM", "DMA", "STOP_MONITOR" ] + } ], + "default" : null + }, { + "name" : "stopMonitorState", + "type" : [ "null", { + "type" : "enum", + "name" : "StopMonitorState", + "symbols" : [ "COM", "DMA", "STOP_MONITOR" ] + } ], + "default" : null + } ] + } ], + "doc" : "Indicates if the order is locked and they type of lock.", + "default" : null + }, { + "name" : "narrative", + "type" : [ "null", { + "type" : "string", + "avro.java.string" : "String" + } ], + "doc" : "Free text for reference. Not used in processing.", + "default" : null + } ] + } + }, { + "name" : "changeInfo", + "type" : { + "type" : "record", + "name" : "Info", + "namespace" : "io.test.trade.v1.order.change", + "fields" : [ { + "name" : "action", + "type" : { + "type" : "enum", + "name" : "Action", + "namespace" : "io.test.trade.v1.common.change", + "symbols" : [ "UPDATE", "DELETE", "NEW", "RESTATE" ] + } + }, { + "name" : "channel", + "type" : [ "null", "io.test.trade.v1.common.Channel" ], + "default" : null + }, { + "name" : "attachedStop", + "type" : [ "null", { + "type" : "record", + "name" : "Stop", + "namespace" : "io.test.trade.v1.common.change.attached", + "fields" : [ { + "name" : "action", + "type" : { + "type" : "enum", + "name" : "Action", + "symbols" : [ "NEW", "DELETED", "UPDATED" ] + } + }, { + "name" : "distance", + "type" : "double" + }, { + "name" : "trailingStop", + "type" : [ "null", { + "type" : "record", + "name" : "TrailingStop", + "fields" : [ { + "name" : "action", + "type" : "Action" + }, { + "name" : "distance", + "type" : "double" + }, { + "name" : "increment", + "type" : "double" + } ] + } ], + "default" : null + } ] + } ], + "default" : null + }, { + "name" : "attachedLimit", + "type" : [ "null", { + "type" : "record", + "name" : "Limit", + "namespace" : "io.test.trade.v1.common.change.attached", + "fields" : [ { + "name" : "action", + "type" : "Action" + }, { + "name" : "distance", + "type" : "double" + } ] + } ], + "default" : null + }, { + "name" : "transactOrderReference", + "type" : [ "null", "io.test.trade.v1.Id" ], + "default" : null + }, { + "name" : "transactTimestamp", + "type" : [ "null", "io.test.trade.v1.common.UTCTimestamp" ], + "default" : null + } ] + } + }, { + "name" : "transaction", + "type" : [ "null", { + "type" : "record", + "name" : "Info", + "namespace" : "io.test.trade.v1.common.transaction", + "fields" : [ { + "name" : "id", + "type" : [ "null", { + "type" : "string", + "avro.java.string" : "String" + } ], + "default" : null + }, { + "name" : "group", + "type" : [ "null", { + "type" : "record", + "name" : "Group", + "fields" : [ { + "name" : "size", + "type" : "int" + }, { + "name" : "messageIndex", + "type" : "int" + } ] + } ], + "default" : null + } ] + } ], + "default" : null + } ] + } ], + "default" : null + }, { + "name" : "properties", + "type" : [ "null", { + "type" : "map", + "values" : { + "type" : "string", + "avro.java.string" : "String" + }, + "avro.java.string" : "String" + } ], + "default" : null + }, { + "name" : "uuid", + "type" : [ "null", { + "type" : "string", + "avro.java.string" : "String" + } ], + "default" : null + }, { + "name" : "clientId", + "type" : [ "null", { + "type" : "string", + "avro.java.string" : "String" + } ], + "default" : null + }, { + "name" : "partitionKey", + "type" : [ "null", { + "type" : "string", + "avro.java.string" : "String" + } ], + "default" : null + }, { + "name" : "correlationId", + "type" : [ "null", { + "type" : "string", + "avro.java.string" : "String" + } ], + "default" : null + }, { + "name" : "clusterId", + "type" : [ "null", { + "type" : "string", + "avro.java.string" : "String" + } ], + "default" : null + } ] +} \ No newline at end of file diff --git a/cross-region-replication-converter/src/test/avro/DocTestRecord.avsc b/cross-region-replication-converter/src/test/avro/DocTestRecord.avsc new file mode 100644 index 00000000..b83348ef --- /dev/null +++ b/cross-region-replication-converter/src/test/avro/DocTestRecord.avsc @@ -0,0 +1,20 @@ +{ + "type" : "record", + "name" : "DocTestRecord", + "namespace" : "io.test.avro.doc", + "doc" : "Some record document.", + "fields" : [ { + "name" : "obj", + "type" : { + "type" : "record", + "name" : "DocTestRecord1", + "doc" : "Some nested record document.", + "fields" : [ { + "name" : "data", + "type" : "string", + "doc" : "Some nested record field document." + } ] + }, + "doc" : "Some field document." + } ] +} \ No newline at end of file diff --git a/cross-region-replication-converter/src/test/avro/Enum.avsc b/cross-region-replication-converter/src/test/avro/Enum.avsc new file mode 100644 index 00000000..4d24ad60 --- /dev/null +++ b/cross-region-replication-converter/src/test/avro/Enum.avsc @@ -0,0 +1,16 @@ +{ + "namespace": "foo.bar", + "type": "record", + "name": "EnumTest", + "fields": [ + {"name": "testkey", "type": "string"}, + { + "name": "kind", + "type": { + "name": "Kind", + "type": "enum", + "symbols" : ["ONE", "TWO", "THREE"] + } + } + ] +} \ No newline at end of file diff --git a/cross-region-replication-converter/src/test/avro/EnumUnion.avsc b/cross-region-replication-converter/src/test/avro/EnumUnion.avsc new file mode 100644 index 00000000..7e90cd0a --- /dev/null +++ b/cross-region-replication-converter/src/test/avro/EnumUnion.avsc @@ -0,0 +1,22 @@ +{ + "type": "record", + "name": "EnumUnion", + "namespace": "com.connect.avro", + "fields": [ + { + "name": "userType", + "type": [ + "null", + { + "type": "enum", + "name": "UserType", + "symbols": [ + "ANONYMOUS", + "REGISTERED" + ] + } + ], + "default": null + } + ] +} \ No newline at end of file diff --git a/cross-region-replication-converter/src/test/avro/MultiTypeUnionMessage.avsc b/cross-region-replication-converter/src/test/avro/MultiTypeUnionMessage.avsc new file mode 100644 index 00000000..35aa90b7 --- /dev/null +++ b/cross-region-replication-converter/src/test/avro/MultiTypeUnionMessage.avsc @@ -0,0 +1,45 @@ +{ + "type": "record", + "name": "MultiTypeUnionMessage", + "namespace": "io.test.avro.union", + "fields": [ + { + "name": "CompositeRecord", + "type": [ + "null", + { + "type": "record", + "name": "FirstOption", + "fields": [ + { + "name": "x", + "type": "string" + }, + { + "name": "y", + "type": "long" + } + ] + }, + { + "type": "record", + "name": "SecondOption", + "fields": [ + { + "name": "a", + "type": "string" + }, + { + "name": "b", + "type": "long" + } + ] + }, + { + "type": "array", + "items": "string" + } + ] + } + ] +} \ No newline at end of file diff --git a/cross-region-replication-converter/src/test/avro/RepeatedTypeWithDefault.avsc b/cross-region-replication-converter/src/test/avro/RepeatedTypeWithDefault.avsc new file mode 100644 index 00000000..12b80326 --- /dev/null +++ b/cross-region-replication-converter/src/test/avro/RepeatedTypeWithDefault.avsc @@ -0,0 +1,45 @@ +{ + "name": "RepeatedTypeWithDefault", + "namespace": "com.rr.avro.test", + "type": "record", + "fields": [ + { + "name": "stringField", + "type": "string", + "default": "field's default" + }, + { + "name": "anotherStringField", + "type": "string" + }, + { + "name": "enumField", + "default": "ONE", + "type": { + "name": "Kind", + "type": "enum", + "symbols" : ["ONE", "TWO", "THREE"] + } + }, + { + "name": "anotherEnumField", + "type": "Kind", + "default": "TWO" + }, + { + "name": "enumFieldWithDiffDefault", + "default": "B", + "type": { + "name": "someKind", + "type": "enum", + "symbols": ["A", "B", "C"], + "default": "A" + } + }, + { + "name": "floatField", + "type": "float", + "default": 9.18 + } + ] +} \ No newline at end of file diff --git a/cross-region-replication-converter/src/test/avro/RepeatedTypeWithDocFull.avsc b/cross-region-replication-converter/src/test/avro/RepeatedTypeWithDocFull.avsc new file mode 100644 index 00000000..5cf02e9b --- /dev/null +++ b/cross-region-replication-converter/src/test/avro/RepeatedTypeWithDocFull.avsc @@ -0,0 +1,96 @@ +{ + "name": "RepeatedTypeWithDoc", + "namespace": "com.rr.avro.test", + "type": "record", + "doc": "record's doc", + "fields": [ + { + "name": "stringField", + "type": "string", + "doc": "field's doc" + }, + { + "name": "anotherStringField", + "type": "string" + }, + { + "name": "recordField", + "doc": "record field's doc", + "type": { + "name": "NestedRecord", + "type": "record", + "doc": "nested record's doc", + "fields": [ + { + "name": "nestedRecordField", + "doc": "nested record field's doc", + "type": { + "name": "FixedType", + "type": "fixed", + "size": 4 + } + }, + { + "name": "anotherNestedRecordField", + "type": "FixedType" + } + ] + } + }, + { + "name": "anotherRecordField", + "type": "NestedRecord", + "doc": "another record field's doc" + }, + { + "name": "recordFieldWithoutDoc", + "type": "NestedRecord" + }, + { + "name": "doclessRecordField", + "type": { + "name": "DoclessNestedRecord", + "type": "record", + "fields": [ + { + "name": "aField", + "type": "string" + } + ] + } + }, + { + "name": "doclessRecordFieldWithDoc", + "type": "DoclessNestedRecord", + "doc": "docless record field's doc" + }, + { + "name": "enumField", + "doc": "enum field's doc", + "type": { + "name": "Kind", + "type": "enum", + "doc": "enum's doc", + "symbols" : ["ONE", "TWO", "THREE"] + } + }, + { + "name": "anotherEnumField", + "type": "Kind", + "doc": "another enum field's doc" + }, + { + "name": "doclessEnumField", + "type": "Kind" + }, + { + "name": "diffEnumField", + "type": { + "name": "anotherKind", + "type": "enum", + "doc": "diffEnum's doc", + "symbols": ["A", "B", "C"] + } + } + ] +} \ No newline at end of file diff --git a/cross-region-replication-converter/src/test/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverterTest.java b/cross-region-replication-converter/src/test/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverterTest.java new file mode 100644 index 00000000..2fd278a8 --- /dev/null +++ b/cross-region-replication-converter/src/test/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverterTest.java @@ -0,0 +1,362 @@ +package com.amazonaws.services.crossregion.schemaregistry.kafkaconnect; + +import com.amazonaws.services.schemaregistry.common.Schema; +import com.amazonaws.services.schemaregistry.deserializers.GlueSchemaRegistryDeserializationFacade; +import com.amazonaws.services.schemaregistry.deserializers.GlueSchemaRegistryDeserializerImpl; +import com.amazonaws.services.schemaregistry.exception.AWSSchemaRegistryException; +import com.amazonaws.services.schemaregistry.serializers.GlueSchemaRegistrySerializationFacade; +import com.amazonaws.services.schemaregistry.serializers.GlueSchemaRegistrySerializerImpl; +import com.amazonaws.services.schemaregistry.utils.AWSSchemaRegistryConstants; +import com.amazonaws.services.schemaregistry.utils.AvroRecordType; + +import org.apache.kafka.connect.data.Struct; +import org.apache.kafka.connect.data.SchemaBuilder; +import org.apache.kafka.connect.errors.DataException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.services.glue.model.DataFormat; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.when; + +/** + * Unit tests for testing RegisterSchema class. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) + +public class CrossRegionReplicationConverterTest { + @Mock + private AwsCredentialsProvider credProvider; + @Mock + private GlueSchemaRegistryDeserializerImpl deserializer; + @Mock + private GlueSchemaRegistrySerializerImpl serializer; + private final static byte[] ENCODED_DATA = new byte[] { 8, 9, 12, 83, 82 }; + private final static byte[] USER_DATA = new byte[] { 12, 83, 82 }; + private static final String testTopic = "User-Topic"; + private CrossRegionReplicationConverter converter; + + byte[] genericBytes = new byte[] {3, 0, -73, -76, -89, -16, -100, -106, 78, 74, -90, -121, -5, + 93, -23, -17, 12, 99, 10, 115, 97, 110, 115, 97, -58, 1, 6, 114, 101, 100}; + byte[] avroBytes = new byte[] {3, 0, 84, 24, 47, -109, 37, 124, 74, 77, -100, + -98, -12, 118, 41, 32, 57, -66, 30, 101, 110, 116, 101, 114, 116, 97, 105, 110, 109, 101, 110, + 116, 95, 50, 0, 0, 0, 0, 0, 0, 20, 64}; + byte[] jsonBytes = new byte[] {3, 0, -73, -76, -89, -16, -100, -106, 78, 74, -90, -121, -5, 93, -23, -17, 12, 99, 123, 34, + 102, 105, 114, 115, 116, 78, 97, 109, 101, 34, 58, 34, 74, 111, 104, 110, 34, 44, 34, 108, 97, + 115, 116, 78, 97, 109, 101, 34, 58, 34, 68, 111, 101, 34, 44, 34, 97, 103, 101, 34, 58, 50, 49, + 125}; + byte[] protobufBytes = "foo".getBytes(StandardCharsets.UTF_8); + + @BeforeEach + void setUp() { + converter = new CrossRegionReplicationConverter(credProvider, deserializer, serializer); + } + + /** + * Test for Converter config method. + */ + @Test + public void testConverter_configure() { + converter = new CrossRegionReplicationConverter(); + converter.configure(getTestProperties(), false); + assertNotNull(converter); + assertNotNull(converter.getCredentialsProvider()); + assertNotNull(converter.getSerializer()); + assertNotNull(converter.getDeserializer()); + assertNotNull(converter.isKey()); + } + + /** + * Test for Converter when source region config is not provided. + */ + @Test + public void testConverter_sourceRegionNotProvided_throwsException(){ + converter = new CrossRegionReplicationConverter(); + Exception exception = assertThrows(DataException.class, () -> converter.configure(getNoSourceRegionProperties(), false)); + assertEquals("Source Region is not provided.", exception.getMessage()); + } + + /** + * Test for Converter when source region config is not provided. + */ + @Test + public void testConverter_targetRegionNotProvided_throwsException(){ + converter = new CrossRegionReplicationConverter(); + Exception exception = assertThrows(DataException.class, () -> converter.configure(getNoTargetRegionProperties(), false)); + assertEquals("Target Region is not provided.", exception.getMessage()); + } + + /** + * Test for Converter when source region config is not provided. + */ + @Test + public void testConverter_targetRegionReplacedByRegion_Succeeds(){ + converter = new CrossRegionReplicationConverter(); + converter.configure(getTargetRegionReplacedProperties(), false); + assertNotNull(converter.getSerializer()); + } + + /** + * Test Converter when it returns null given the input value is null. + */ + @Test + public void testConverter_fromConnectData_returnsByte0() { + Struct expected = createStructRecord(); + assertNull(converter.fromConnectData(testTopic, expected.schema(), null)); + } + + /** + * Test Converter when serializer throws exception with Avro schema. + */ + @Test + public void testConverter_fromConnectData_serializer_avroSchema_throwsException() { + Schema SCHEMA_REGISTRY_SCHEMA = new Schema("{}", DataFormat.AVRO.name(), "schemaFoo"); + Struct expected = createStructRecord(); + doReturn(USER_DATA) + .when(deserializer).getData(genericBytes); + doReturn(SCHEMA_REGISTRY_SCHEMA) + .when(deserializer).getSchema(genericBytes); + when(serializer.encode(testTopic, SCHEMA_REGISTRY_SCHEMA, USER_DATA)).thenThrow(new AWSSchemaRegistryException()); + assertThrows(DataException.class, () -> converter.fromConnectData(testTopic, expected.schema(), genericBytes)); + } + + /** + * Test Converter when the deserializer throws exception with Avro schema. + */ + @Test + public void testConverter_fromConnectData_deserializer_avroSchema_throwsException() { + Schema SCHEMA_REGISTRY_SCHEMA = new Schema("{}", DataFormat.AVRO.name(), "schemaFoo"); + Struct expected = createStructRecord(); + when((deserializer).getData(genericBytes)).thenThrow(new AWSSchemaRegistryException()); + doReturn(SCHEMA_REGISTRY_SCHEMA) + .when(deserializer).getSchema(genericBytes); + doReturn(ENCODED_DATA) + .when(serializer).encode(null, SCHEMA_REGISTRY_SCHEMA, USER_DATA); + assertThrows(DataException.class, () -> converter.fromConnectData(testTopic, expected.schema(), genericBytes)); + } + + /** + * Test Converter when Avro schema is replicated. + */ + @Test + public void testConverter_fromConnectData_avroSchema_succeeds() { + String schemaDefinition = """ + {"namespace": "com.amazonaws.services.schemaregistry.serializers.avro", + "type": "record", + "name": "payment", + "fields": [ + {"name": "id", "type": "string"}, + {"name": "id_6", "type": "double"} + ]}"""; + Schema testSchema = new Schema(schemaDefinition, DataFormat.AVRO.name(), testTopic); + Struct expected = createStructRecord(); + doReturn(genericBytes). + when(deserializer).getData(avroBytes); + doReturn(testSchema). + when(deserializer).getSchema(avroBytes); + doReturn(ENCODED_DATA) + .when(serializer).encode(testTopic, testSchema, genericBytes); + assertEquals(converter.fromConnectData(testTopic, expected.schema(), avroBytes), ENCODED_DATA); + } + + /** + * Test Converter when serializer throws exception with JSON schema. + */ + @Test + public void testConverter_fromConnectData_serializer_jsonSchema_throwsException() { + Schema SCHEMA_REGISTRY_SCHEMA = new Schema("{}", DataFormat.JSON.name(), "schemaFoo"); + Struct expected = createStructRecord(); + doReturn(USER_DATA) + .when(deserializer).getData(genericBytes); + doReturn(SCHEMA_REGISTRY_SCHEMA) + .when(deserializer).getSchema(genericBytes); + when(serializer.encode(testTopic, SCHEMA_REGISTRY_SCHEMA, USER_DATA)).thenThrow(new AWSSchemaRegistryException()); + assertThrows(DataException.class, () -> converter.fromConnectData(testTopic, expected.schema(), genericBytes)); + } + + /** + * Test Converter when the deserializer throws exception with JSON schema. + */ + @Test + public void testConverter_fromConnectData_deserializer_jsonSchema_throwsException() { + Schema SCHEMA_REGISTRY_SCHEMA = new Schema("{}", DataFormat.JSON.name(), "schemaFoo"); + Struct expected = createStructRecord(); + when((deserializer).getData(genericBytes)).thenThrow(new AWSSchemaRegistryException()); + doReturn(SCHEMA_REGISTRY_SCHEMA) + .when(deserializer).getSchema(genericBytes); + doReturn(ENCODED_DATA) + .when(serializer).encode("schemaFoo", SCHEMA_REGISTRY_SCHEMA, USER_DATA); + assertThrows(DataException.class, () -> converter.fromConnectData(testTopic, expected.schema(), genericBytes)); + } + + /** + * Test Converter when JSON schema is replicated. + */ + @Test + public void testConverter_fromConnectData_jsonSchema_succeeds() { + String testSchemaDefinition = "{\"$id\":\"https://example.com/geographical-location.schema.json\"," + + "\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"title\":\"Longitude " + + "and Latitude Values\",\"description\":\"A geographical coordinate.\"," + + "\"required\":[\"latitude\",\"longitude\"],\"type\":\"object\"," + + "\"properties\":{\"latitude\":{\"type\":\"number\",\"minimum\":-90," + + "\"maximum\":90},\"longitude\":{\"type\":\"number\",\"minimum\":-180," + + "\"maximum\":180}},\"additionalProperties\":false}"; + Schema testSchema = new Schema(testSchemaDefinition, DataFormat.JSON.name(), testTopic); + Struct expected = createStructRecord(); + doReturn(genericBytes). + when(deserializer).getData(jsonBytes); + doReturn(testSchema). + when(deserializer).getSchema(jsonBytes); + doReturn(ENCODED_DATA) + .when(serializer).encode(testTopic, testSchema, genericBytes); + assertEquals(converter.fromConnectData(testTopic, expected.schema(), jsonBytes), ENCODED_DATA); + } + + /** + * Test Converter when serializer throws exception with protobuf schema. + */ + @Test + public void testConverter_fromConnectData_serializer_protobufSchema_throwsException() { + Schema SCHEMA_REGISTRY_SCHEMA = new Schema("{}", DataFormat.PROTOBUF.name(), "schemaFoo"); + Struct expected = createStructRecord(); + doReturn(USER_DATA) + .when(deserializer).getData(genericBytes); + doReturn(SCHEMA_REGISTRY_SCHEMA) + .when(deserializer).getSchema(genericBytes); + when(serializer.encode(testTopic, SCHEMA_REGISTRY_SCHEMA, USER_DATA)).thenThrow(new AWSSchemaRegistryException()); + assertThrows(DataException.class, () -> converter.fromConnectData(testTopic, expected.schema(), genericBytes)); + } + + /** + * Test Converter when the deserializer throws exception with protobuf schema. + */ + @Test + public void testConverter_fromConnectData_deserializer_protobufSchema_throwsException() { + Schema SCHEMA_REGISTRY_SCHEMA = new Schema("{}", DataFormat.PROTOBUF.name(), "schemaFoo"); + Struct expected = createStructRecord(); + when((deserializer).getData(genericBytes)).thenThrow(new AWSSchemaRegistryException()); + doReturn(SCHEMA_REGISTRY_SCHEMA) + .when(deserializer).getSchema(genericBytes); + doReturn(ENCODED_DATA) + .when(serializer).encode("schemaFoo", SCHEMA_REGISTRY_SCHEMA, USER_DATA); + assertThrows(DataException.class, () -> converter.fromConnectData(testTopic, expected.schema(), genericBytes)); + } + + /** + * Test Converter when Protobuf schema is replicated. + */ + @Test + public void getSchema_protobuf_succeeds(){ + Schema testSchema = new Schema("foo", DataFormat.PROTOBUF.name(), testTopic); + Struct expected = createStructRecord(); + doReturn(genericBytes). + when(deserializer).getData(protobufBytes); + doReturn(testSchema). + when(deserializer).getSchema(protobufBytes); + doReturn(ENCODED_DATA) + .when(serializer).encode(testTopic, testSchema, genericBytes); + assertEquals(converter.fromConnectData(testTopic, expected.schema(), protobufBytes), ENCODED_DATA); + } + + /** + * Test toConnectData when IllegalAccessException is thrown. + */ + @Test + public void toConnectData_throwsException(){ + assertThrows(UnsupportedOperationException.class, () -> converter.toConnectData(testTopic, genericBytes)); + } + + /** + * To create a map of configurations without source region. + * + * @return a map of configurations + */ + private Map getNoSourceRegionProperties() { + Map props = new HashMap<>(); + + props.put(AWSSchemaRegistryConstants.AWS_REGION, "us-east-1"); + props.put(AWSSchemaRegistryConstants.AWS_TARGET_REGION, "us-east-1"); + props.put(AWSSchemaRegistryConstants.AWS_ENDPOINT, "https://test"); + props.put(AWSSchemaRegistryConstants.SCHEMA_AUTO_REGISTRATION_SETTING, true); + props.put(AWSSchemaRegistryConstants.AVRO_RECORD_TYPE, AvroRecordType.GENERIC_RECORD.getName()); + + return props; + } + + /** + * To create a map of configurations without target region. + * + * @return a map of configurations + */ + private Map getNoTargetRegionProperties() { + Map props = new HashMap<>(); + + props.put(AWSSchemaRegistryConstants.AWS_SOURCE_REGION, "us-west-2"); + props.put(AWSSchemaRegistryConstants.AWS_ENDPOINT, "https://test"); + props.put(AWSSchemaRegistryConstants.SCHEMA_AUTO_REGISTRATION_SETTING, true); + props.put(AWSSchemaRegistryConstants.AVRO_RECORD_TYPE, AvroRecordType.GENERIC_RECORD.getName()); + + return props; + } + + /** + * To create a map of configurations without target region, but is replaced by the provided region config. + * + * @return a map of configurations + */ + private Map getTargetRegionReplacedProperties() { + Map props = new HashMap<>(); + + props.put(AWSSchemaRegistryConstants.AWS_REGION, "us-east-1"); + props.put(AWSSchemaRegistryConstants.AWS_SOURCE_REGION, "us-west-2"); + props.put(AWSSchemaRegistryConstants.AWS_ENDPOINT, "https://test"); + props.put(AWSSchemaRegistryConstants.SCHEMA_AUTO_REGISTRATION_SETTING, true); + props.put(AWSSchemaRegistryConstants.AVRO_RECORD_TYPE, AvroRecordType.GENERIC_RECORD.getName()); + + return props; + } + + /** + * To create a map of configurations. + * + * @return a map of configurations + */ + private Map getTestProperties() { + Map props = new HashMap<>(); + + props.put(AWSSchemaRegistryConstants.AWS_SOURCE_REGION, "us-west-2"); + props.put(AWSSchemaRegistryConstants.AWS_TARGET_REGION, "us-east-1"); + props.put(AWSSchemaRegistryConstants.REGISTRY_NAME, "default-registry"); + props.put(AWSSchemaRegistryConstants.SCHEMA_NAME, "test_schema"); + props.put(AWSSchemaRegistryConstants.AWS_ENDPOINT, "https://test"); + props.put(AWSSchemaRegistryConstants.AWS_SOURCE_ENDPOINT, "https://test"); + props.put(AWSSchemaRegistryConstants.SCHEMA_AUTO_REGISTRATION_SETTING, true); + props.put(AWSSchemaRegistryConstants.AVRO_RECORD_TYPE, AvroRecordType.GENERIC_RECORD.getName()); + + return props; + } + + /** + * To create a Connect Struct record. + * + * @return a Connect Struct + */ + private Struct createStructRecord() { + org.apache.kafka.connect.data.Schema schema = SchemaBuilder.struct() + .build(); + return new Struct(schema); + } +} diff --git a/cross-region-replication-mm2-converter/pom.xml b/cross-region-replication-mm2-converter/pom.xml deleted file mode 100644 index 583e526d..00000000 --- a/cross-region-replication-mm2-converter/pom.xml +++ /dev/null @@ -1,164 +0,0 @@ - - - 4.0.0 - - org.example - CrossRegionReplicationMm2Converter - 1.0-SNAPSHOT - - - 20 - 20 - UTF-8 - - - - - org.apache.kafka - kafka-clients - 3.2.3 - - - - org.apache.kafka - kafka-streams - 3.2.3 - - - - org.apache.kafka - connect-api - 3.2.3 - - - - software.amazon.glue - schema-registry-serde - 1.1.15 - - - - software.amazon.glue - schema-registry-common - 1.1.15 - - - - org.apache.avro - avro - 1.7.7 - - - - - - - - - - - - - - - - com.amazonaws - aws-java-sdk-sts - 1.12.151 - - - software.amazon.awssdk - sts - 2.17.122 - - - software.amazon.glue - schema-registry-common - 1.1.15 - - - software.amazon.glue - schema-registry-kafkastreams-serde - 1.1.15 - - - software.amazon.glue - schema-registry-kafkaconnect-converter - 1.1.5 - - - software.amazon.awssdk - arns - 2.17.122 - - - - com.amazonaws - aws-java-sdk-glue - 1.12.497 - - - - com.google.protobuf - protobuf-java - 3.19.6 - test - - - - org.projectlombok - lombok - 1.18.24 - provided - - - - org.mockito - mockito-core - 5.3.1 - test - - - org.mockito - mockito-junit-jupiter - 5.3.1 - test - - - - junit - junit - 4.12 - test - - - org.junit.jupiter - junit-jupiter-engine - 5.9.1 - test - - - org.junit.jupiter - junit-jupiter-params - 5.9.1 - test - - - org.junit.platform - junit-platform-commons - 1.9.2 - test - - - org.junit.jupiter - junit-jupiter-api - 5.9.1 - test - - - - - - diff --git a/cross-region-replication-mm2-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2Converter.java b/cross-region-replication-mm2-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2Converter.java deleted file mode 100644 index 8ac614fe..00000000 --- a/cross-region-replication-mm2-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2Converter.java +++ /dev/null @@ -1,161 +0,0 @@ -package com.amazonaws.services.crossregion.schemaregistry.kafkaconnect; - -import com.amazonaws.services.schemaregistry.common.AWSSerializerInput; -import com.amazonaws.services.schemaregistry.common.Schema; -import com.amazonaws.services.schemaregistry.common.configs.GlueSchemaRegistryConfiguration; -import com.amazonaws.services.schemaregistry.deserializers.GlueSchemaRegistryDeserializationFacade; -import com.amazonaws.services.schemaregistry.deserializers.GlueSchemaRegistryDeserializerImpl; -import com.amazonaws.services.schemaregistry.exception.AWSSchemaRegistryException; -import com.amazonaws.services.schemaregistry.serializers.GlueSchemaRegistrySerializationFacade; -import com.amazonaws.services.schemaregistry.serializers.GlueSchemaRegistrySerializerImpl; -import com.amazonaws.services.schemaregistry.utils.AWSSchemaRegistryConstants; - -import kotlinx.serialization.SerializationException; -import lombok.Getter; - -import org.apache.kafka.connect.data.*; -import org.apache.kafka.connect.errors.DataException; -import org.apache.kafka.connect.storage.Converter; -import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; -import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; - -import java.util.HashMap; -import java.util.Map; -import java.util.UUID; - -public class CrossRegionReplicationMM2Converter implements Converter { - @Getter - private GlueSchemaRegistryDeserializationFacade deserializationFacade; - @Getter - private GlueSchemaRegistrySerializationFacade serializationFacade; - @Getter - private AwsCredentialsProvider credentialsProvider; - @Getter - private GlueSchemaRegistryDeserializerImpl deserializer; - @Getter - private GlueSchemaRegistrySerializerImpl serializer; - @Getter - private boolean isKey; - - /** - * Constructor used by Kafka Connect user. - */ - public CrossRegionReplicationMM2Converter(){}; - - /** - * Constructor accepting AWSCredentialsProvider. - * - * @param credentialsProvider AWSCredentialsProvider instance. - */ - public CrossRegionReplicationMM2Converter( - GlueSchemaRegistryDeserializationFacade deserializationFacade, - GlueSchemaRegistrySerializationFacade serializationFacade, - AwsCredentialsProvider credentialsProvider, - GlueSchemaRegistryDeserializerImpl deserializerImpl, - GlueSchemaRegistrySerializerImpl serializerImpl) { - this.deserializationFacade = deserializationFacade; - this.serializationFacade = serializationFacade; - this.credentialsProvider = credentialsProvider; - this.deserializer = deserializerImpl; - this.serializer = serializerImpl; - } - - /** - * Configure the MM2 Schema Replication Converter. - * @param configs configuration elements for the converter - * @param isKey true if key, false otherwise - */ - @Override - public void configure(Map configs, boolean isKey) { - this.isKey = isKey; - new CrossRegionReplicationMM2ConverterConfig(configs); - - - credentialsProvider = DefaultCredentialsProvider.builder().build(); - - // Put the source and target regions into configurations respectively - Map sourceConfigs = new HashMap<>(configs); - Map targetConfigs = new HashMap<>(configs); - - sourceConfigs.put(AWSSchemaRegistryConstants.AWS_REGION, configs.get(AWSSchemaRegistryConstants.AWS_SRC_REGION)); - targetConfigs.put(AWSSchemaRegistryConstants.AWS_REGION, configs.get(AWSSchemaRegistryConstants.AWS_TGT_REGION)); - - deserializationFacade = - GlueSchemaRegistryDeserializationFacade.builder() - .credentialProvider(credentialsProvider) - .configs(sourceConfigs) - .build(); - - serializationFacade = - GlueSchemaRegistrySerializationFacade.builder() - .credentialProvider(credentialsProvider) - .configs(targetConfigs) - .build(); - - serializer = new GlueSchemaRegistrySerializerImpl(credentialsProvider, new GlueSchemaRegistryConfiguration(targetConfigs)); - - deserializer = new GlueSchemaRegistryDeserializerImpl(credentialsProvider, new GlueSchemaRegistryConfiguration(sourceConfigs)); - - -// deserializationFacade = new GlueSchemaRegistryDeserializationFacade(new GlueSchemaRegistryConfiguration(sourceConfigs), credentialsProvider); -// -// serializationFacade = new GlueSchemaRegistrySerializationFacade(credentialsProvider, null, new GlueSchemaRegistryConfiguration(targetConfigs), null, null); - - - } - - @Override - public byte[] fromConnectData(String topic, org.apache.kafka.connect.data.Schema schema, Object value) { - byte[] bytes = (byte[]) value; - if (value == null) return new byte[0]; - - try { - byte[] deserializedBytes = deserializer.getData(bytes); - Schema deserializedSchema = deserializer.getSchema(bytes); - - byte[] encodedByte = serializer.encode(null, deserializedSchema, deserializedBytes); - com.amazonaws.services.schemaregistry.common.Schema returnedSchema = getSchema(bytes); - UUID uuid = registerSchema(returnedSchema); - - return encodedByte; - } catch (SerializationException | AWSSchemaRegistryException e){ - throw new DataException("Converting Kafka Connect data to byte[] failed due to serialization/deserialization error: ", e); - } - - } - - @Override - public SchemaAndValue toConnectData(String s, byte[] bytes) { - return null; - } - - /** - * Retrieve schema from source region GSR using schema header of the serialized messages - * @param data serialized message obtained from MM2 - * @return schema - */ - public Schema getSchema(byte[] data){ - if (data == null) { - throw new NullPointerException("Empty Data"); - } - return deserializationFacade.getSchema(data); - - } - - /** - * Register schema in the target region GSR - * @param schema schema obtained from the source region GSR - * @return schema version ID of the registered schema - */ - public UUID registerSchema(com.amazonaws.services.schemaregistry.common.Schema schema){ - try{ - String schemaDefinition = schema.getSchemaDefinition(); - String schemaName = schema.getSchemaName(); - String dataFormat = schema.getDataFormat(); - AWSSerializerInput input = new AWSSerializerInput(schemaDefinition, schemaName, dataFormat, null); - return serializationFacade.getOrRegisterSchemaVersion(input); - } catch (Exception e){ - throw new DataException("Schema can't be register"); - } - } -} diff --git a/cross-region-replication-mm2-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2ConverterConfig.java b/cross-region-replication-mm2-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2ConverterConfig.java deleted file mode 100644 index 1a66c4e6..00000000 --- a/cross-region-replication-mm2-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2ConverterConfig.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.amazonaws.services.crossregion.schemaregistry.kafkaconnect; - -import org.apache.kafka.common.config.AbstractConfig; -import org.apache.kafka.common.config.ConfigDef; - -import java.util.Map; - -public class CrossRegionReplicationMM2ConverterConfig extends AbstractConfig { - - public static ConfigDef configDef() { - return new ConfigDef(); - } - /** - * Constructor used by CrossRegionReplicationMM2Converter. - * @param props property elements for the converter config - */ - public CrossRegionReplicationMM2ConverterConfig(Map props) { - super(configDef(), props); - } -} diff --git a/cross-region-replication-mm2-converter/src/main/resources/META-INF/maven/archetype.xml b/cross-region-replication-mm2-converter/src/main/resources/META-INF/maven/archetype.xml deleted file mode 100644 index 104a3b2c..00000000 --- a/cross-region-replication-mm2-converter/src/main/resources/META-INF/maven/archetype.xml +++ /dev/null @@ -1,9 +0,0 @@ - - cross-region-replication-mm2-converter - - src/main/java/App.java - - - src/test/java/AppTest.java - - diff --git a/cross-region-replication-mm2-converter/src/main/resources/archetype-resources/pom.xml b/cross-region-replication-mm2-converter/src/main/resources/archetype-resources/pom.xml deleted file mode 100644 index 96f1bced..00000000 --- a/cross-region-replication-mm2-converter/src/main/resources/archetype-resources/pom.xml +++ /dev/null @@ -1,15 +0,0 @@ - - 4.0.0 - $org.example - $cross-region-replication-mm2-converter - $1.1.15 - - - junit - junit - 3.8.1 - test - - - diff --git a/cross-region-replication-mm2-converter/src/main/resources/archetype-resources/src/main/java/App.java b/cross-region-replication-mm2-converter/src/main/resources/archetype-resources/src/main/java/App.java deleted file mode 100644 index 1fa6a956..00000000 --- a/cross-region-replication-mm2-converter/src/main/resources/archetype-resources/src/main/java/App.java +++ /dev/null @@ -1,13 +0,0 @@ -package $org.example; - -/** - * Hello world! - * - */ -public class App -{ - public static void main( String[] args ) - { - System.out.println( "Hello World!" ); - } -} diff --git a/cross-region-replication-mm2-converter/src/main/resources/archetype-resources/src/test/java/AppTest.java b/cross-region-replication-mm2-converter/src/main/resources/archetype-resources/src/test/java/AppTest.java deleted file mode 100644 index 65be417e..00000000 --- a/cross-region-replication-mm2-converter/src/main/resources/archetype-resources/src/test/java/AppTest.java +++ /dev/null @@ -1,38 +0,0 @@ -package $org.example; - -import junit.framework.Test; -import junit.framework.TestCase; -import junit.framework.TestSuite; - -/** - * Unit test for simple App. - */ -public class AppTest - extends TestCase -{ - /** - * Create the test case - * - * @param testName name of the test case - */ - public AppTest( String testName ) - { - super( testName ); - } - - /** - * @return the suite of tests being tested - */ - public static Test suite() - { - return new TestSuite( AppTest.class ); - } - - /** - * Rigourous Test :-) - */ - public void testApp() - { - assertTrue( true ); - } -} diff --git a/cross-region-replication-mm2-converter/src/test/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2ConverterTest.java b/cross-region-replication-mm2-converter/src/test/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2ConverterTest.java deleted file mode 100644 index a0175512..00000000 --- a/cross-region-replication-mm2-converter/src/test/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2ConverterTest.java +++ /dev/null @@ -1,344 +0,0 @@ -package com.amazonaws.services.crossregion.schemaregistry.kafkaconnect; - -import com.amazonaws.services.schemaregistry.common.AWSSerializerInput; -import com.amazonaws.services.schemaregistry.common.Schema; -import com.amazonaws.services.schemaregistry.deserializers.GlueSchemaRegistryDeserializationFacade; -import com.amazonaws.services.schemaregistry.deserializers.GlueSchemaRegistryDeserializerImpl; -import com.amazonaws.services.schemaregistry.exception.AWSSchemaRegistryException; -import com.amazonaws.services.schemaregistry.serializers.GlueSchemaRegistrySerializationFacade; -import com.amazonaws.services.schemaregistry.serializers.GlueSchemaRegistrySerializerImpl; -import com.amazonaws.services.schemaregistry.utils.AWSSchemaRegistryConstants; -import com.amazonaws.services.schemaregistry.utils.AvroRecordType; - -import org.apache.kafka.connect.data.Struct; -import org.apache.kafka.connect.data.SchemaBuilder; -import org.apache.kafka.connect.errors.DataException; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.mockito.junit.jupiter.MockitoSettings; -import org.mockito.quality.Strictness; -import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; -import software.amazon.awssdk.services.glue.model.DataFormat; - -import java.nio.charset.StandardCharsets; -import java.util.Arrays; -import java.util.HashMap; -import java.util.Map; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.when; - -/** - * Unit tests for testing RegisterSchema class. - */ -@ExtendWith(MockitoExtension.class) -@MockitoSettings(strictness = Strictness.LENIENT) - -public class CrossRegionReplicationMM2ConverterTest { - @Mock - private GlueSchemaRegistryDeserializationFacade deserializationFacade; - @Mock - private GlueSchemaRegistrySerializationFacade serializationFacade; - @Mock - private AwsCredentialsProvider credProvider; - @Mock - private GlueSchemaRegistryDeserializerImpl deserializer; - @Mock - private GlueSchemaRegistrySerializerImpl serializer; - private final static byte[] ENCODED_DATA = new byte[] { 8, 9, 12, 83, 82 }; - private final static byte[] USER_DATA = new byte[] { 12, 83, 82 }; - private final static Schema SCHEMA_REGISTRY_SCHEMA = new Schema("{}", "AVRO", "schemaFoo"); - private static final String testTopic = "User-Topic"; - - byte[] genericBytes = new byte[] {3, 0, -73, -76, -89, -16, -100, -106, 78, 74, -90, -121, -5, - 93, -23, -17, 12, 99, 10, 115, 97, 110, 115, 97, -58, 1, 6, 114, 101, 100}; - - - byte[] avroBytes = new byte[] {3, 0, 84, 24, 47, -109, 37, 124, 74, 77, -100, - -98, -12, 118, 41, 32, 57, -66, 30, 101, 110, 116, 101, 114, 116, 97, 105, 110, 109, 101, 110, - 116, 95, 50, 0, 0, 0, 0, 0, 0, 20, 64}; - - private CrossRegionReplicationMM2Converter converter; - - - - @BeforeEach - void setUp() { - converter = new CrossRegionReplicationMM2Converter(deserializationFacade, serializationFacade, credProvider, deserializer, serializer); - } - - /** - * Test for Converter config method. - */ - @Test - public void testConverter_configure() { - converter = new CrossRegionReplicationMM2Converter(); - converter.configure(getTestProperties(), false); - assertNotNull(converter); - assertNotNull(converter.getDeserializationFacade()); - assertNotNull(converter.getSerializationFacade()); - assertNotNull(converter.getCredentialsProvider()); - assertNotNull(converter.getSerializer()); - assertNotNull(converter.getDeserializer()); - assertNotNull(converter.isKey()); - - } - - /** - * Test Mm2Converter when it returns byte 0 given the input value is null. - */ - @Test - public void testConverter_fromConnectData_returnsByte0() { - Struct expected = createStructRecord(); - assertEquals(Arrays.toString(converter.fromConnectData(testTopic, expected.schema(), null)), Arrays.toString(new byte[0])); - } - - /** - * Test Mm2Converter when serializer throws exception. - */ - @Test - public void testConverter_fromConnectData_throwsException() { - Struct expected = createStructRecord(); - doReturn(USER_DATA) - .when(deserializer).getData(genericBytes); - doReturn(SCHEMA_REGISTRY_SCHEMA) - .when(deserializer).getSchema(genericBytes); - when(serializer.encode(null, SCHEMA_REGISTRY_SCHEMA, USER_DATA)).thenThrow(new AWSSchemaRegistryException()); - assertThrows(DataException.class, () -> converter.fromConnectData(testTopic, expected.schema(), genericBytes)); - } - - /** - * Test Mm2Converter when the deserializer throws exception. - */ - @Test - public void testConverter_fromConnectData_deserializer_getData_ThrowsException() { - Struct expected = createStructRecord(); - when((deserializer).getData(genericBytes)).thenThrow(new AWSSchemaRegistryException()); - doReturn(SCHEMA_REGISTRY_SCHEMA) - .when(deserializer).getSchema(genericBytes); - doReturn(ENCODED_DATA) - .when(serializer).encode(null, SCHEMA_REGISTRY_SCHEMA, USER_DATA); - assertThrows(DataException.class, () -> converter.fromConnectData(testTopic, expected.schema(), genericBytes)); - } - - /** - * Test getSchema when NullPointerException is thrown. - */ - @Test - public void testGetSchema_nullObject_throwsException(){ - assertThrows(NullPointerException.class, () -> converter.getSchema(null)); - } - - - /** - * Test getSchema when an existing Avro schema is being successfully retrieved. - */ - @Test - public void getSchema_avro_succeeds(){ - - String schemaDefinition = """ - {"namespace": "com.amazonaws.services.schemaregistry.serializers.avro", - "type": "record", - "name": "payment", - "fields": [ - {"name": "id", "type": "string"}, - {"name": "id_6", "type": "double"} - ]}"""; - Schema testSchema = new Schema(schemaDefinition, DataFormat.AVRO.name(), testTopic); - - doReturn(testSchema). - when(deserializationFacade).getSchema(avroBytes); - Schema returnedSchema = converter.getSchema(avroBytes); - - assertEquals(returnedSchema.getSchemaDefinition(), testSchema.getSchemaDefinition()); - assertEquals(returnedSchema.getSchemaName(), testSchema.getSchemaName()); - assertEquals(returnedSchema.getDataFormat(), testSchema.getDataFormat()); - } - - /** - * Test getSchema when an existing JSON schema is being successfully retrieved. - */ - @Test - public void getSchema_json_succeeds() { - - String testSchemaDefinition = "{\"$id\":\"https://example.com/geographical-location.schema.json\"," - + "\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"title\":\"Longitude " - + "and Latitude Values\",\"description\":\"A geographical coordinate.\"," - + "\"required\":[\"latitude\",\"longitude\"],\"type\":\"object\"," - + "\"properties\":{\"latitude\":{\"type\":\"number\",\"minimum\":-90," - + "\"maximum\":90},\"longitude\":{\"type\":\"number\",\"minimum\":-180," - + "\"maximum\":180}},\"additionalProperties\":false}"; - - String jsonData = "{\"latitude\":48.858093,\"longitude\":2.294694}"; - byte[] jsonBytes = jsonData.getBytes(StandardCharsets.UTF_8); - Schema testSchema = new Schema(testSchemaDefinition, DataFormat.JSON.name(), testTopic); - - doReturn(testSchema). - when(deserializationFacade).getSchema(jsonBytes); - Schema returnedSchema = converter.getSchema(jsonBytes); - - assertEquals(returnedSchema.getSchemaDefinition(), testSchema.getSchemaDefinition()); - assertEquals(returnedSchema.getSchemaName(), testSchema.getSchemaName()); - assertEquals(returnedSchema.getDataFormat(), testSchema.getDataFormat()); - } - - /** - * Test getSchema when an existing Protobuf schema is being successfully retrieved. - */ - @Test - public void getSchema_protobuf_succeeds(){ - - Schema testSchema = new Schema("foo", DataFormat.PROTOBUF.name(), testTopic); - byte[] protobufBytes = "foo".getBytes(StandardCharsets.UTF_8); - - doReturn(testSchema). - when(deserializationFacade).getSchema(protobufBytes); - Schema returnedSchema = converter.getSchema(protobufBytes); - - assertEquals(returnedSchema.getSchemaDefinition(), testSchema.getSchemaDefinition()); - assertEquals(returnedSchema.getSchemaName(), testSchema.getSchemaName()); - assertEquals(returnedSchema.getDataFormat(), testSchema.getDataFormat()); - } - - /** - * Test registerSchema when NullPointerException is thrown. - */ - @Test - public void registerSchema_throwsDataException() { - assertThrows(DataException.class, () -> converter.registerSchema(null)); - } - - /** - * Test registerSchema for existing Avro schema. - */ - @Test - public void registerSchema_avro_nonExisting_succeeds() { - - String schemaDefinition = """ - {"namespace": "com.amazonaws.services.schemaregistry.serializers.avro", - "type": "record", - "name": "payment", - "fields": [ - {"name": "id", "type": "string"}, - {"name": "id_6", "type": "double"} - ]}"""; - UUID avroBytesVersionID = UUID.fromString("54182f93-257c-4a4d-9c9e-f476292039be"); - - Schema testSchema = new Schema(schemaDefinition, DataFormat.AVRO.name(), testTopic); - AWSSerializerInput input = new AWSSerializerInput(testSchema.getSchemaDefinition(), testSchema.getSchemaName(), testSchema.getDataFormat(), null); - - doReturn(avroBytesVersionID). - when(serializationFacade).getOrRegisterSchemaVersion(input); - - assertEquals(converter.registerSchema(testSchema), avroBytesVersionID); - } - - /** - * Test registerSchema for existing JSON schema. - */ - @Test - public void registerSchema_json_nonExisting_succeeds() { - String testSchemaDefinition = "{\"$id\":\"https://example.com/geographical-location.schema.json\"," - + "\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"title\":\"Longitude " - + "and Latitude Values\",\"description\":\"A geographical coordinate.\"," - + "\"required\":[\"latitude\",\"longitude\"],\"type\":\"object\"," - + "\"properties\":{\"latitude\":{\"type\":\"number\",\"minimum\":-90," - + "\"maximum\":90},\"longitude\":{\"type\":\"number\",\"minimum\":-180," - + "\"maximum\":180}},\"additionalProperties\":false}"; - UUID jsonBytesVersionID = UUID.fromString("afba13fd-7c25-4202-8904-1fab3089faf9"); - - Schema testSchema = new Schema(testSchemaDefinition, DataFormat.JSON.name(), "testJson"); - AWSSerializerInput input = new AWSSerializerInput(testSchema.getSchemaDefinition(), testSchema.getSchemaName(), testSchema.getDataFormat(), null); - - doReturn(jsonBytesVersionID). - when(serializationFacade).getOrRegisterSchemaVersion(input); - - assertEquals(converter.registerSchema(testSchema), jsonBytesVersionID); - } - - /** - * Test registerSchema for non-existing Protobuf schema. - */ - @Test - public void registerSchema_protobuf_nonExisting_succeeds() { - String testSchemaDefinition = "foo"; - UUID protobufBytesVersionID = UUID.fromString("b7b4a7f0-9c96-4e4a-a687-fb5de9ef0c63"); - - Schema testSchema = new Schema(testSchemaDefinition, DataFormat.PROTOBUF.name(), testTopic); - AWSSerializerInput input = new AWSSerializerInput(testSchema.getSchemaDefinition(), testSchema.getSchemaName(), testSchema.getDataFormat(), null); - - doReturn(protobufBytesVersionID). - when(serializationFacade).getOrRegisterSchemaVersion(input); - - assertEquals(converter.registerSchema(testSchema), protobufBytesVersionID); - } - - - /** - * To create a map of configurations w/o source region. - * - * @return a map of configurations - */ - private Map getNoSourceProperties() { - Map props = new HashMap<>(); - - props.put("TARGET_REGION", "us-west-2"); - props.put(AWSSchemaRegistryConstants.AWS_ENDPOINT, "https://test"); - props.put(AWSSchemaRegistryConstants.SCHEMA_AUTO_REGISTRATION_SETTING, true); - props.put(AWSSchemaRegistryConstants.AVRO_RECORD_TYPE, AvroRecordType.GENERIC_RECORD.getName()); - - return props; - } - - /** - * To create a map of configurations w/o source region. - * - * @return a map of configurations - */ - private Map getNoTargetProperties() { - Map props = new HashMap<>(); - - props.put("SOURCE_REGION", "us-west-2"); - props.put(AWSSchemaRegistryConstants.AWS_ENDPOINT, "https://test"); - props.put(AWSSchemaRegistryConstants.SCHEMA_AUTO_REGISTRATION_SETTING, true); - props.put(AWSSchemaRegistryConstants.AVRO_RECORD_TYPE, AvroRecordType.GENERIC_RECORD.getName()); - - return props; - } - - /** - * To create a map of configurations. - * - * @return a map of configurations - */ - private Map getTestProperties() { - Map props = new HashMap<>(); - - props.put("SOURCE_REGION", "us-west-2"); - props.put("TARGET_REGION", "us-east-1"); - props.put(AWSSchemaRegistryConstants.REGISTRY_NAME, "default-registry"); - props.put(AWSSchemaRegistryConstants.SCHEMA_NAME, "t2"); - props.put(AWSSchemaRegistryConstants.AWS_ENDPOINT, "https://test"); - props.put(AWSSchemaRegistryConstants.AWS_SRC_ENDPOINT, "https://test"); - props.put(AWSSchemaRegistryConstants.SCHEMA_AUTO_REGISTRATION_SETTING, true); - props.put(AWSSchemaRegistryConstants.AVRO_RECORD_TYPE, AvroRecordType.GENERIC_RECORD.getName()); - - return props; - } - - /** - * To create a Connect Struct record. - * - * @return a Connect Struct - */ - private Struct createStructRecord() { - org.apache.kafka.connect.data.Schema schema = SchemaBuilder.struct() - .build(); - return new Struct(schema); - } -} diff --git a/pom.xml b/pom.xml index b843cecc..dee2738a 100644 --- a/pom.xml +++ b/pom.xml @@ -74,7 +74,7 @@ integration-tests jsonschema-kafkaconnect-converter protobuf-kafkaconnect-converter - cross-region-replication-mm2-converter + cross-region-replication-converter From 7c365e2d30ceb912d2b3e9d12ef1c090561f8ed3 Mon Sep 17 00:00:00 2001 From: Jessie Chen Date: Wed, 26 Jul 2023 13:55:03 -0700 Subject: [PATCH 03/41] Modified Converter Based on First CR Feedback --- .../common/configs/GlueSchemaRegistryConfiguration.java | 3 ++- .../kafkaconnect/CrossRegionReplicationConverter.java | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/common/src/main/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfiguration.java b/common/src/main/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfiguration.java index cc40c6fc..28b92367 100644 --- a/common/src/main/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfiguration.java +++ b/common/src/main/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfiguration.java @@ -44,7 +44,8 @@ public class GlueSchemaRegistryConfiguration { private AWSSchemaRegistryConstants.COMPRESSION compressionType = AWSSchemaRegistryConstants.COMPRESSION.NONE; private String endPoint; private String region; - //TODO: Remove configs that are not useful non replication use-cases. + // TODO: Remove configs that are not useful non replication use-cases. + // https://github.com/awslabs/aws-glue-schema-registry/issues/292 private String sourceEndPoint; private String sourceRegion; private String targetEndPoint; diff --git a/cross-region-replication-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverter.java b/cross-region-replication-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverter.java index a15ad0ce..d97943cc 100644 --- a/cross-region-replication-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverter.java +++ b/cross-region-replication-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverter.java @@ -55,7 +55,8 @@ public CrossRegionReplicationConverter( @Override public void configure(Map configs, boolean isKey) { this.isKey = isKey; - //TODO: Support credentialProvider passed on by the user + // TODO: Support credentialProvider passed on by the user + // https://github.com/awslabs/aws-glue-schema-registry/issues/293 credentialsProvider = DefaultCredentialsProvider.builder().build(); // Put the source and target regions into configurations respectively @@ -85,6 +86,8 @@ public byte[] fromConnectData(String topic, org.apache.kafka.connect.data.Schema byte[] deserializedBytes = deserializer.getData(bytes); Schema deserializedSchema = deserializer.getSchema(bytes); //The registry is decided by the configuration in the target region , schema name is the same as the source region + // TODO: Prefix topic name with source cluster alias + // https://github.com/awslabs/aws-glue-schema-registry/issues/294 return serializer.encode(topic, deserializedSchema, deserializedBytes); } catch (SerializationException | AWSSchemaRegistryException e){ From 0ef7d0317023ecaafd6378a2ce15c7dee8712af5 Mon Sep 17 00:00:00 2001 From: Jessie Chen Date: Fri, 18 Aug 2023 10:40:36 -0700 Subject: [PATCH 04/41] Added TODOs --- .../common/configs/GlueSchemaRegistryConfiguration.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/main/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfiguration.java b/common/src/main/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfiguration.java index 28b92367..4b704fa3 100644 --- a/common/src/main/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfiguration.java +++ b/common/src/main/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfiguration.java @@ -44,7 +44,7 @@ public class GlueSchemaRegistryConfiguration { private AWSSchemaRegistryConstants.COMPRESSION compressionType = AWSSchemaRegistryConstants.COMPRESSION.NONE; private String endPoint; private String region; - // TODO: Remove configs that are not useful non replication use-cases. + // TODO: Remove configs that are not useful non replication use-cases // https://github.com/awslabs/aws-glue-schema-registry/issues/292 private String sourceEndPoint; private String sourceRegion; From e4c45b32eb04a1804be245a0edd5c8bee32bb691 Mon Sep 17 00:00:00 2001 From: Jessie Chen Date: Tue, 25 Jul 2023 21:59:07 -0700 Subject: [PATCH 05/41] Created MM2 Converter and Added Unit Tests --- common/pom.xml | 8 + .../common/AWSSchemaRegistryClient.java | 15 +- .../GlueSchemaRegistryConfiguration.java | 62 +++- .../utils/AWSSchemaRegistryConstants.java | 23 +- .../GlueSchemaRegistryConfigurationTest.java | 53 +-- .../.gitignore | 38 ++ .../pom.xml | 164 +++++++++ .../CrossRegionReplicationMM2Converter.java | 161 ++++++++ ...ssRegionReplicationMM2ConverterConfig.java | 20 + .../resources/META-INF/maven/archetype.xml | 9 + .../resources/archetype-resources/pom.xml | 15 + .../src/main/java/App.java | 13 + .../src/test/java/AppTest.java | 38 ++ ...rossRegionReplicationMM2ConverterTest.java | 344 ++++++++++++++++++ pom.xml | 1 + 15 files changed, 906 insertions(+), 58 deletions(-) create mode 100644 cross-region-replication-mm2-converter/.gitignore create mode 100644 cross-region-replication-mm2-converter/pom.xml create mode 100644 cross-region-replication-mm2-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2Converter.java create mode 100644 cross-region-replication-mm2-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2ConverterConfig.java create mode 100644 cross-region-replication-mm2-converter/src/main/resources/META-INF/maven/archetype.xml create mode 100644 cross-region-replication-mm2-converter/src/main/resources/archetype-resources/pom.xml create mode 100644 cross-region-replication-mm2-converter/src/main/resources/archetype-resources/src/main/java/App.java create mode 100644 cross-region-replication-mm2-converter/src/main/resources/archetype-resources/src/test/java/AppTest.java create mode 100644 cross-region-replication-mm2-converter/src/test/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2ConverterTest.java diff --git a/common/pom.xml b/common/pom.xml index 14267498..a18e1ce0 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -65,6 +65,14 @@ software.amazon.awssdk glue + + + org.apache.maven.plugins + maven-shade-plugin + 3.2.1 + maven-plugin + + ${parent.groupId} schema-registry-build-tools diff --git a/common/src/main/java/com/amazonaws/services/schemaregistry/common/AWSSchemaRegistryClient.java b/common/src/main/java/com/amazonaws/services/schemaregistry/common/AWSSchemaRegistryClient.java index 949e6563..b3b48af5 100644 --- a/common/src/main/java/com/amazonaws/services/schemaregistry/common/AWSSchemaRegistryClient.java +++ b/common/src/main/java/com/amazonaws/services/schemaregistry/common/AWSSchemaRegistryClient.java @@ -90,18 +90,19 @@ public AWSSchemaRegistryClient(@NonNull AwsCredentialsProvider credentialsProvid .retryPolicy(retryPolicy) .addExecutionInterceptor(new UserAgentRequestInterceptor()) .build(); - UrlConnectionHttpClient.Builder urlConnectionHttpClientBuilder = UrlConnectionHttpClient.builder(); - if (glueSchemaRegistryConfiguration.getProxyUrl() != null) { - log.debug("Creating http client using proxy {}", glueSchemaRegistryConfiguration.getProxyUrl().toString()); - ProxyConfiguration proxy = ProxyConfiguration.builder().endpoint(glueSchemaRegistryConfiguration.getProxyUrl()).build(); - urlConnectionHttpClientBuilder.proxyConfiguration(proxy); - } +// UrlConnectionHttpClient.Builder urlConnectionHttpClientBuilder = UrlConnectionHttpClient.builder(); +// if (glueSchemaRegistryConfiguration.getProxyUrl() != null) { +// log.debug("Creating http client using proxy {}", glueSchemaRegistryConfiguration.getProxyUrl().toString()); +// ProxyConfiguration proxy = ProxyConfiguration.builder().endpoint(glueSchemaRegistryConfiguration.getProxyUrl()).build(); +// urlConnectionHttpClientBuilder.proxyConfiguration(proxy); +// } GlueClientBuilder glueClientBuilder = GlueClient .builder() .credentialsProvider(credentialsProvider) .overrideConfiguration(overrideConfiguration) - .httpClient(urlConnectionHttpClientBuilder.build()) +// .httpClient(urlConnectionHttpClientBuilder.build()) + .httpClient(UrlConnectionHttpClient.builder().build()) .region(Region.of(glueSchemaRegistryConfiguration.getRegion())); if (glueSchemaRegistryConfiguration.getEndPoint() != null) { diff --git a/common/src/main/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfiguration.java b/common/src/main/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfiguration.java index ce7d870d..ebc2b68c 100644 --- a/common/src/main/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfiguration.java +++ b/common/src/main/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfiguration.java @@ -29,7 +29,6 @@ import software.amazon.awssdk.regions.providers.DefaultAwsRegionProviderChain; import software.amazon.awssdk.services.glue.model.Compatibility; -import java.net.URI; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -46,6 +45,10 @@ public class GlueSchemaRegistryConfiguration { private AWSSchemaRegistryConstants.COMPRESSION compressionType = AWSSchemaRegistryConstants.COMPRESSION.NONE; private String endPoint; private String region; + private String srcEndPoint; + private String srcRegion; + private String tgtEndPoint; + private String tgtRegion; private long timeToLiveMillis = 24 * 60 * 60 * 1000L; private int cacheSize = 200; private AvroRecordType avroRecordType; @@ -57,7 +60,6 @@ public class GlueSchemaRegistryConfiguration { private Map tags = new HashMap<>(); private Map metadata; private String secondaryDeserializer; - private URI proxyUrl; /** * Name of the application using the serializer/deserializer. @@ -88,6 +90,10 @@ private void buildConfigs(Map configs) { } private void buildSchemaRegistryConfigs(Map configs) { + validateAndSetAWSSrcRegion(configs); + validateAndSetAWSSrcEndpoint(configs); + validateAndSetAWSTgtRegion(configs); + validateAndSetAWSTgtEndpoint(configs); validateAndSetAWSRegion(configs); validateAndSetAWSEndpoint(configs); validateAndSetRegistryName(configs); @@ -103,7 +109,6 @@ private void buildSchemaRegistryConfigs(Map configs) { validateAndSetMetadata(configs); validateAndSetUserAgent(configs); validateAndSetSecondaryDeserializer(configs); - validateAndSetProxyUrl(configs); } private void validateAndSetSecondaryDeserializer(Map configs) { @@ -142,7 +147,7 @@ private boolean validateCompressionType(String compressionType) { if (!EnumUtils.isValidEnum(AWSSchemaRegistryConstants.COMPRESSION.class, compressionType.toUpperCase())) { String errorMessage = String.format("Invalid Compression type : %s, Accepted values are : %s", compressionType, - AWSSchemaRegistryConstants.COMPRESSION.values()); + AWSSchemaRegistryConstants.COMPRESSION.values()); throw new AWSSchemaRegistryException(errorMessage); } return true; @@ -164,6 +169,20 @@ private void validateAndSetAWSRegion(Map configs) { } } + private void validateAndSetAWSSrcRegion(Map configs) { + if (isPresent(configs, AWSSchemaRegistryConstants.AWS_SRC_REGION)) { + this.srcRegion = String.valueOf(configs.get(AWSSchemaRegistryConstants.AWS_SRC_REGION)); + } + } + + private void validateAndSetAWSTgtRegion(Map configs) { + if (isPresent(configs, AWSSchemaRegistryConstants.AWS_TGT_REGION)) { + this.tgtRegion = String.valueOf(configs.get(AWSSchemaRegistryConstants.AWS_TGT_REGION)); + } else { + this.tgtRegion = String.valueOf(configs.get(AWSSchemaRegistryConstants.AWS_REGION)); + } + } + private void validateAndSetCompatibility(Map configs) { if (isPresent(configs, AWSSchemaRegistryConstants.COMPATIBILITY_SETTING)) { this.compatibilitySetting = Compatibility.fromValue( @@ -171,10 +190,10 @@ private void validateAndSetCompatibility(Map configs) { .toUpperCase()); if (this.compatibilitySetting == null - || this.compatibilitySetting == Compatibility.UNKNOWN_TO_SDK_VERSION) { + || this.compatibilitySetting == Compatibility.UNKNOWN_TO_SDK_VERSION) { String errorMessage = String.format("Invalid compatibility setting : %s, Accepted values are : %s", - configs.get(AWSSchemaRegistryConstants.COMPATIBILITY_SETTING), - Compatibility.knownValues()); + configs.get(AWSSchemaRegistryConstants.COMPATIBILITY_SETTING), + Compatibility.knownValues()); throw new AWSSchemaRegistryException(errorMessage); } } else { @@ -196,18 +215,21 @@ private void validateAndSetAWSEndpoint(Map configs) { } } - private void validateAndSetProxyUrl(Map configs) { - if (isPresent(configs, AWSSchemaRegistryConstants.PROXY_URL)) { - String value = (String) configs.get(AWSSchemaRegistryConstants.PROXY_URL); - try { - this.proxyUrl = URI.create(value); - } catch (IllegalArgumentException e) { - String message = String.format("Proxy URL property is not a valid URL: %s", value); - throw new AWSSchemaRegistryException(message, e); - } + private void validateAndSetAWSSrcEndpoint(Map configs) { + if (isPresent(configs, AWSSchemaRegistryConstants.AWS_SRC_ENDPOINT)) { + this.srcEndPoint = String.valueOf(configs.get(AWSSchemaRegistryConstants.AWS_SRC_ENDPOINT)); + } + } + + private void validateAndSetAWSTgtEndpoint(Map configs) { + if (isPresent(configs, AWSSchemaRegistryConstants.AWS_TGT_ENDPOINT)) { + this.tgtEndPoint = String.valueOf(configs.get(AWSSchemaRegistryConstants.AWS_TGT_ENDPOINT)); + } else { + this.tgtEndPoint = String.valueOf(configs.get(AWSSchemaRegistryConstants.AWS_ENDPOINT)); } } + private void validateAndSetDescription(Map configs) throws AWSSchemaRegistryException { if (isPresent(configs, AWSSchemaRegistryConstants.DESCRIPTION)) { this.description = String.valueOf(configs.get(AWSSchemaRegistryConstants.DESCRIPTION)); @@ -266,7 +288,7 @@ private void validateAndSetSchemaAutoRegistrationSetting(Map configs) .toString()); } else { log.info("schemaAutoRegistrationEnabled is not defined in the properties. Using the default value {}", - schemaAutoRegistrationEnabled); + schemaAutoRegistrationEnabled); } } @@ -335,9 +357,9 @@ private boolean isPresent(Map configs, private Map getMapFromPropertiesFile(Properties properties) { return new HashMap<>(properties.entrySet() - .stream() - .collect(Collectors.toMap(e -> e.getKey() - .toString(), e -> e.getValue()))); + .stream() + .collect(Collectors.toMap(e -> e.getKey() + .toString(), e -> e.getValue()))); } private String buildDescriptionFromProperties() throws AWSSchemaRegistryException { diff --git a/common/src/main/java/com/amazonaws/services/schemaregistry/utils/AWSSchemaRegistryConstants.java b/common/src/main/java/com/amazonaws/services/schemaregistry/utils/AWSSchemaRegistryConstants.java index 5ce30b06..6aaa0772 100644 --- a/common/src/main/java/com/amazonaws/services/schemaregistry/utils/AWSSchemaRegistryConstants.java +++ b/common/src/main/java/com/amazonaws/services/schemaregistry/utils/AWSSchemaRegistryConstants.java @@ -18,10 +18,6 @@ import software.amazon.awssdk.services.glue.model.Compatibility; public final class AWSSchemaRegistryConstants { - /** - * Proxy URL to use while connecting to AWS endpoint. - */ - public static final String PROXY_URL = "proxyUrl"; /** * AWS endpoint to use while initializing the client for service. */ @@ -30,6 +26,23 @@ public final class AWSSchemaRegistryConstants { * AWS region to use while initializing the client for service. */ public static final String AWS_REGION = "region"; + /** + * TODO: CR_GSR: AWS source endpoint to use while initializing the client for service. + */ + public static final String AWS_SRC_ENDPOINT = "source.endpoint"; + /** + * TODO: CR_GSR: AWS source region to use while initializing the client for service. + */ + public static final String AWS_SRC_REGION = "source.region"; + /** + * AWS target endpoint to use while initializing the client for service. + */ + public static final String AWS_TGT_ENDPOINT = "target.endpoint"; + /** + * AWS target region to use while initializing the client for service. + */ + public static final String AWS_TGT_REGION = "target.region"; + /** * Header Version Byte. */ @@ -216,4 +229,4 @@ public enum COMPRESSION { */ ZLIB } -} +} \ No newline at end of file diff --git a/common/src/test/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfigurationTest.java b/common/src/test/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfigurationTest.java index 70613e06..09d31f36 100644 --- a/common/src/test/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfigurationTest.java +++ b/common/src/test/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfigurationTest.java @@ -275,8 +275,8 @@ public void testBuildConfig_invalidCompressionType_throwsException() { @Test public void testBuildConfig_validTags_succeeds() { Map testTags = new HashMap<>(); - testTags.put("testTagKey","testTagValue"); - testTags.put("testTagKey2","testTagValue2"); + testTags.put("testTagKey", "testTagValue"); + testTags.put("testTagKey2", "testTagValue2"); configs.put(AWSSchemaRegistryConstants.TAGS, testTags); GlueSchemaRegistryConfiguration serDeConfigs = new GlueSchemaRegistryConfiguration(configs); @@ -296,8 +296,8 @@ public void testBuildConfig_validTags_succeeds() { public void testBuildConfigWithProperties_validTags_succeeds() { Properties props = createTestProperties(); HashMap testTags = new HashMap<>(); - testTags.put("testTagKey","testTagValue"); - testTags.put("testTagKey2","testTagValue2"); + testTags.put("testTagKey", "testTagValue"); + testTags.put("testTagKey2", "testTagValue2"); props.put(AWSSchemaRegistryConstants.TAGS, testTags); GlueSchemaRegistryConfiguration serDeConfigs = new GlueSchemaRegistryConfiguration(props); @@ -388,27 +388,28 @@ public void testValidateAndSetRegistryName_withRegistryConfig_throwsException() assertEquals(expectedRegistryName, glueSchemaRegistryConfiguration.getRegistryName()); } - /** - * Tests valid proxy URL value. - */ - @Test - public void testBuildConfig_validProxyUrl_success() { - Properties props = createTestProperties(); - String proxy = "http://proxy.servers.url:8080"; - props.put(AWSSchemaRegistryConstants.PROXY_URL, proxy); - GlueSchemaRegistryConfiguration glueSchemaRegistryConfiguration = new GlueSchemaRegistryConfiguration(props); - assertEquals(URI.create(proxy), glueSchemaRegistryConfiguration.getProxyUrl()); - } - /** - * Tests invalid proxy URL value. - */ - @Test - public void testBuildConfig_invalidProxyUrl_throwsException() { - Properties props = createTestProperties(); - String proxy = "http:// proxy.url: 8080"; - props.put(AWSSchemaRegistryConstants.PROXY_URL, "http:// proxy.url: 8080"); - Exception exception = assertThrows(AWSSchemaRegistryException.class, () -> new GlueSchemaRegistryConfiguration(props)); - assertEquals("Proxy URL property is not a valid URL: "+proxy, exception.getMessage()); - } +// /** +// * Tests valid proxy URL value. +// */ +// @Test +// public void testBuildConfig_validProxyUrl_success() { +// Properties props = createTestProperties(); +// String proxy = "http://proxy.servers.url:8080"; +// props.put(AWSSchemaRegistryConstants.PROXY_URL, proxy); +// GlueSchemaRegistryConfiguration glueSchemaRegistryConfiguration = new GlueSchemaRegistryConfiguration(props); +// assertEquals(URI.create(proxy), glueSchemaRegistryConfiguration.getProxyUrl()); +// } +// +// /** +// * Tests invalid proxy URL value. +// */ +// @Test +// public void testBuildConfig_invalidProxyUrl_throwsException() { +// Properties props = createTestProperties(); +// String proxy = "http:// proxy.url: 8080"; +// props.put(AWSSchemaRegistryConstants.PROXY_URL, "http:// proxy.url: 8080"); +// Exception exception = assertThrows(AWSSchemaRegistryException.class, () -> new GlueSchemaRegistryConfiguration(props)); +// assertEquals("Proxy URL property is not a valid URL: "+proxy, exception.getMessage()); +// } } \ No newline at end of file diff --git a/cross-region-replication-mm2-converter/.gitignore b/cross-region-replication-mm2-converter/.gitignore new file mode 100644 index 00000000..5ff6309b --- /dev/null +++ b/cross-region-replication-mm2-converter/.gitignore @@ -0,0 +1,38 @@ +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/cross-region-replication-mm2-converter/pom.xml b/cross-region-replication-mm2-converter/pom.xml new file mode 100644 index 00000000..583e526d --- /dev/null +++ b/cross-region-replication-mm2-converter/pom.xml @@ -0,0 +1,164 @@ + + + 4.0.0 + + org.example + CrossRegionReplicationMm2Converter + 1.0-SNAPSHOT + + + 20 + 20 + UTF-8 + + + + + org.apache.kafka + kafka-clients + 3.2.3 + + + + org.apache.kafka + kafka-streams + 3.2.3 + + + + org.apache.kafka + connect-api + 3.2.3 + + + + software.amazon.glue + schema-registry-serde + 1.1.15 + + + + software.amazon.glue + schema-registry-common + 1.1.15 + + + + org.apache.avro + avro + 1.7.7 + + + + + + + + + + + + + + + + com.amazonaws + aws-java-sdk-sts + 1.12.151 + + + software.amazon.awssdk + sts + 2.17.122 + + + software.amazon.glue + schema-registry-common + 1.1.15 + + + software.amazon.glue + schema-registry-kafkastreams-serde + 1.1.15 + + + software.amazon.glue + schema-registry-kafkaconnect-converter + 1.1.5 + + + software.amazon.awssdk + arns + 2.17.122 + + + + com.amazonaws + aws-java-sdk-glue + 1.12.497 + + + + com.google.protobuf + protobuf-java + 3.19.6 + test + + + + org.projectlombok + lombok + 1.18.24 + provided + + + + org.mockito + mockito-core + 5.3.1 + test + + + org.mockito + mockito-junit-jupiter + 5.3.1 + test + + + + junit + junit + 4.12 + test + + + org.junit.jupiter + junit-jupiter-engine + 5.9.1 + test + + + org.junit.jupiter + junit-jupiter-params + 5.9.1 + test + + + org.junit.platform + junit-platform-commons + 1.9.2 + test + + + org.junit.jupiter + junit-jupiter-api + 5.9.1 + test + + + + + + diff --git a/cross-region-replication-mm2-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2Converter.java b/cross-region-replication-mm2-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2Converter.java new file mode 100644 index 00000000..8ac614fe --- /dev/null +++ b/cross-region-replication-mm2-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2Converter.java @@ -0,0 +1,161 @@ +package com.amazonaws.services.crossregion.schemaregistry.kafkaconnect; + +import com.amazonaws.services.schemaregistry.common.AWSSerializerInput; +import com.amazonaws.services.schemaregistry.common.Schema; +import com.amazonaws.services.schemaregistry.common.configs.GlueSchemaRegistryConfiguration; +import com.amazonaws.services.schemaregistry.deserializers.GlueSchemaRegistryDeserializationFacade; +import com.amazonaws.services.schemaregistry.deserializers.GlueSchemaRegistryDeserializerImpl; +import com.amazonaws.services.schemaregistry.exception.AWSSchemaRegistryException; +import com.amazonaws.services.schemaregistry.serializers.GlueSchemaRegistrySerializationFacade; +import com.amazonaws.services.schemaregistry.serializers.GlueSchemaRegistrySerializerImpl; +import com.amazonaws.services.schemaregistry.utils.AWSSchemaRegistryConstants; + +import kotlinx.serialization.SerializationException; +import lombok.Getter; + +import org.apache.kafka.connect.data.*; +import org.apache.kafka.connect.errors.DataException; +import org.apache.kafka.connect.storage.Converter; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +public class CrossRegionReplicationMM2Converter implements Converter { + @Getter + private GlueSchemaRegistryDeserializationFacade deserializationFacade; + @Getter + private GlueSchemaRegistrySerializationFacade serializationFacade; + @Getter + private AwsCredentialsProvider credentialsProvider; + @Getter + private GlueSchemaRegistryDeserializerImpl deserializer; + @Getter + private GlueSchemaRegistrySerializerImpl serializer; + @Getter + private boolean isKey; + + /** + * Constructor used by Kafka Connect user. + */ + public CrossRegionReplicationMM2Converter(){}; + + /** + * Constructor accepting AWSCredentialsProvider. + * + * @param credentialsProvider AWSCredentialsProvider instance. + */ + public CrossRegionReplicationMM2Converter( + GlueSchemaRegistryDeserializationFacade deserializationFacade, + GlueSchemaRegistrySerializationFacade serializationFacade, + AwsCredentialsProvider credentialsProvider, + GlueSchemaRegistryDeserializerImpl deserializerImpl, + GlueSchemaRegistrySerializerImpl serializerImpl) { + this.deserializationFacade = deserializationFacade; + this.serializationFacade = serializationFacade; + this.credentialsProvider = credentialsProvider; + this.deserializer = deserializerImpl; + this.serializer = serializerImpl; + } + + /** + * Configure the MM2 Schema Replication Converter. + * @param configs configuration elements for the converter + * @param isKey true if key, false otherwise + */ + @Override + public void configure(Map configs, boolean isKey) { + this.isKey = isKey; + new CrossRegionReplicationMM2ConverterConfig(configs); + + + credentialsProvider = DefaultCredentialsProvider.builder().build(); + + // Put the source and target regions into configurations respectively + Map sourceConfigs = new HashMap<>(configs); + Map targetConfigs = new HashMap<>(configs); + + sourceConfigs.put(AWSSchemaRegistryConstants.AWS_REGION, configs.get(AWSSchemaRegistryConstants.AWS_SRC_REGION)); + targetConfigs.put(AWSSchemaRegistryConstants.AWS_REGION, configs.get(AWSSchemaRegistryConstants.AWS_TGT_REGION)); + + deserializationFacade = + GlueSchemaRegistryDeserializationFacade.builder() + .credentialProvider(credentialsProvider) + .configs(sourceConfigs) + .build(); + + serializationFacade = + GlueSchemaRegistrySerializationFacade.builder() + .credentialProvider(credentialsProvider) + .configs(targetConfigs) + .build(); + + serializer = new GlueSchemaRegistrySerializerImpl(credentialsProvider, new GlueSchemaRegistryConfiguration(targetConfigs)); + + deserializer = new GlueSchemaRegistryDeserializerImpl(credentialsProvider, new GlueSchemaRegistryConfiguration(sourceConfigs)); + + +// deserializationFacade = new GlueSchemaRegistryDeserializationFacade(new GlueSchemaRegistryConfiguration(sourceConfigs), credentialsProvider); +// +// serializationFacade = new GlueSchemaRegistrySerializationFacade(credentialsProvider, null, new GlueSchemaRegistryConfiguration(targetConfigs), null, null); + + + } + + @Override + public byte[] fromConnectData(String topic, org.apache.kafka.connect.data.Schema schema, Object value) { + byte[] bytes = (byte[]) value; + if (value == null) return new byte[0]; + + try { + byte[] deserializedBytes = deserializer.getData(bytes); + Schema deserializedSchema = deserializer.getSchema(bytes); + + byte[] encodedByte = serializer.encode(null, deserializedSchema, deserializedBytes); + com.amazonaws.services.schemaregistry.common.Schema returnedSchema = getSchema(bytes); + UUID uuid = registerSchema(returnedSchema); + + return encodedByte; + } catch (SerializationException | AWSSchemaRegistryException e){ + throw new DataException("Converting Kafka Connect data to byte[] failed due to serialization/deserialization error: ", e); + } + + } + + @Override + public SchemaAndValue toConnectData(String s, byte[] bytes) { + return null; + } + + /** + * Retrieve schema from source region GSR using schema header of the serialized messages + * @param data serialized message obtained from MM2 + * @return schema + */ + public Schema getSchema(byte[] data){ + if (data == null) { + throw new NullPointerException("Empty Data"); + } + return deserializationFacade.getSchema(data); + + } + + /** + * Register schema in the target region GSR + * @param schema schema obtained from the source region GSR + * @return schema version ID of the registered schema + */ + public UUID registerSchema(com.amazonaws.services.schemaregistry.common.Schema schema){ + try{ + String schemaDefinition = schema.getSchemaDefinition(); + String schemaName = schema.getSchemaName(); + String dataFormat = schema.getDataFormat(); + AWSSerializerInput input = new AWSSerializerInput(schemaDefinition, schemaName, dataFormat, null); + return serializationFacade.getOrRegisterSchemaVersion(input); + } catch (Exception e){ + throw new DataException("Schema can't be register"); + } + } +} diff --git a/cross-region-replication-mm2-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2ConverterConfig.java b/cross-region-replication-mm2-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2ConverterConfig.java new file mode 100644 index 00000000..1a66c4e6 --- /dev/null +++ b/cross-region-replication-mm2-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2ConverterConfig.java @@ -0,0 +1,20 @@ +package com.amazonaws.services.crossregion.schemaregistry.kafkaconnect; + +import org.apache.kafka.common.config.AbstractConfig; +import org.apache.kafka.common.config.ConfigDef; + +import java.util.Map; + +public class CrossRegionReplicationMM2ConverterConfig extends AbstractConfig { + + public static ConfigDef configDef() { + return new ConfigDef(); + } + /** + * Constructor used by CrossRegionReplicationMM2Converter. + * @param props property elements for the converter config + */ + public CrossRegionReplicationMM2ConverterConfig(Map props) { + super(configDef(), props); + } +} diff --git a/cross-region-replication-mm2-converter/src/main/resources/META-INF/maven/archetype.xml b/cross-region-replication-mm2-converter/src/main/resources/META-INF/maven/archetype.xml new file mode 100644 index 00000000..104a3b2c --- /dev/null +++ b/cross-region-replication-mm2-converter/src/main/resources/META-INF/maven/archetype.xml @@ -0,0 +1,9 @@ + + cross-region-replication-mm2-converter + + src/main/java/App.java + + + src/test/java/AppTest.java + + diff --git a/cross-region-replication-mm2-converter/src/main/resources/archetype-resources/pom.xml b/cross-region-replication-mm2-converter/src/main/resources/archetype-resources/pom.xml new file mode 100644 index 00000000..96f1bced --- /dev/null +++ b/cross-region-replication-mm2-converter/src/main/resources/archetype-resources/pom.xml @@ -0,0 +1,15 @@ + + 4.0.0 + $org.example + $cross-region-replication-mm2-converter + $1.1.15 + + + junit + junit + 3.8.1 + test + + + diff --git a/cross-region-replication-mm2-converter/src/main/resources/archetype-resources/src/main/java/App.java b/cross-region-replication-mm2-converter/src/main/resources/archetype-resources/src/main/java/App.java new file mode 100644 index 00000000..1fa6a956 --- /dev/null +++ b/cross-region-replication-mm2-converter/src/main/resources/archetype-resources/src/main/java/App.java @@ -0,0 +1,13 @@ +package $org.example; + +/** + * Hello world! + * + */ +public class App +{ + public static void main( String[] args ) + { + System.out.println( "Hello World!" ); + } +} diff --git a/cross-region-replication-mm2-converter/src/main/resources/archetype-resources/src/test/java/AppTest.java b/cross-region-replication-mm2-converter/src/main/resources/archetype-resources/src/test/java/AppTest.java new file mode 100644 index 00000000..65be417e --- /dev/null +++ b/cross-region-replication-mm2-converter/src/main/resources/archetype-resources/src/test/java/AppTest.java @@ -0,0 +1,38 @@ +package $org.example; + +import junit.framework.Test; +import junit.framework.TestCase; +import junit.framework.TestSuite; + +/** + * Unit test for simple App. + */ +public class AppTest + extends TestCase +{ + /** + * Create the test case + * + * @param testName name of the test case + */ + public AppTest( String testName ) + { + super( testName ); + } + + /** + * @return the suite of tests being tested + */ + public static Test suite() + { + return new TestSuite( AppTest.class ); + } + + /** + * Rigourous Test :-) + */ + public void testApp() + { + assertTrue( true ); + } +} diff --git a/cross-region-replication-mm2-converter/src/test/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2ConverterTest.java b/cross-region-replication-mm2-converter/src/test/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2ConverterTest.java new file mode 100644 index 00000000..a0175512 --- /dev/null +++ b/cross-region-replication-mm2-converter/src/test/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2ConverterTest.java @@ -0,0 +1,344 @@ +package com.amazonaws.services.crossregion.schemaregistry.kafkaconnect; + +import com.amazonaws.services.schemaregistry.common.AWSSerializerInput; +import com.amazonaws.services.schemaregistry.common.Schema; +import com.amazonaws.services.schemaregistry.deserializers.GlueSchemaRegistryDeserializationFacade; +import com.amazonaws.services.schemaregistry.deserializers.GlueSchemaRegistryDeserializerImpl; +import com.amazonaws.services.schemaregistry.exception.AWSSchemaRegistryException; +import com.amazonaws.services.schemaregistry.serializers.GlueSchemaRegistrySerializationFacade; +import com.amazonaws.services.schemaregistry.serializers.GlueSchemaRegistrySerializerImpl; +import com.amazonaws.services.schemaregistry.utils.AWSSchemaRegistryConstants; +import com.amazonaws.services.schemaregistry.utils.AvroRecordType; + +import org.apache.kafka.connect.data.Struct; +import org.apache.kafka.connect.data.SchemaBuilder; +import org.apache.kafka.connect.errors.DataException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.services.glue.model.DataFormat; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.when; + +/** + * Unit tests for testing RegisterSchema class. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) + +public class CrossRegionReplicationMM2ConverterTest { + @Mock + private GlueSchemaRegistryDeserializationFacade deserializationFacade; + @Mock + private GlueSchemaRegistrySerializationFacade serializationFacade; + @Mock + private AwsCredentialsProvider credProvider; + @Mock + private GlueSchemaRegistryDeserializerImpl deserializer; + @Mock + private GlueSchemaRegistrySerializerImpl serializer; + private final static byte[] ENCODED_DATA = new byte[] { 8, 9, 12, 83, 82 }; + private final static byte[] USER_DATA = new byte[] { 12, 83, 82 }; + private final static Schema SCHEMA_REGISTRY_SCHEMA = new Schema("{}", "AVRO", "schemaFoo"); + private static final String testTopic = "User-Topic"; + + byte[] genericBytes = new byte[] {3, 0, -73, -76, -89, -16, -100, -106, 78, 74, -90, -121, -5, + 93, -23, -17, 12, 99, 10, 115, 97, 110, 115, 97, -58, 1, 6, 114, 101, 100}; + + + byte[] avroBytes = new byte[] {3, 0, 84, 24, 47, -109, 37, 124, 74, 77, -100, + -98, -12, 118, 41, 32, 57, -66, 30, 101, 110, 116, 101, 114, 116, 97, 105, 110, 109, 101, 110, + 116, 95, 50, 0, 0, 0, 0, 0, 0, 20, 64}; + + private CrossRegionReplicationMM2Converter converter; + + + + @BeforeEach + void setUp() { + converter = new CrossRegionReplicationMM2Converter(deserializationFacade, serializationFacade, credProvider, deserializer, serializer); + } + + /** + * Test for Converter config method. + */ + @Test + public void testConverter_configure() { + converter = new CrossRegionReplicationMM2Converter(); + converter.configure(getTestProperties(), false); + assertNotNull(converter); + assertNotNull(converter.getDeserializationFacade()); + assertNotNull(converter.getSerializationFacade()); + assertNotNull(converter.getCredentialsProvider()); + assertNotNull(converter.getSerializer()); + assertNotNull(converter.getDeserializer()); + assertNotNull(converter.isKey()); + + } + + /** + * Test Mm2Converter when it returns byte 0 given the input value is null. + */ + @Test + public void testConverter_fromConnectData_returnsByte0() { + Struct expected = createStructRecord(); + assertEquals(Arrays.toString(converter.fromConnectData(testTopic, expected.schema(), null)), Arrays.toString(new byte[0])); + } + + /** + * Test Mm2Converter when serializer throws exception. + */ + @Test + public void testConverter_fromConnectData_throwsException() { + Struct expected = createStructRecord(); + doReturn(USER_DATA) + .when(deserializer).getData(genericBytes); + doReturn(SCHEMA_REGISTRY_SCHEMA) + .when(deserializer).getSchema(genericBytes); + when(serializer.encode(null, SCHEMA_REGISTRY_SCHEMA, USER_DATA)).thenThrow(new AWSSchemaRegistryException()); + assertThrows(DataException.class, () -> converter.fromConnectData(testTopic, expected.schema(), genericBytes)); + } + + /** + * Test Mm2Converter when the deserializer throws exception. + */ + @Test + public void testConverter_fromConnectData_deserializer_getData_ThrowsException() { + Struct expected = createStructRecord(); + when((deserializer).getData(genericBytes)).thenThrow(new AWSSchemaRegistryException()); + doReturn(SCHEMA_REGISTRY_SCHEMA) + .when(deserializer).getSchema(genericBytes); + doReturn(ENCODED_DATA) + .when(serializer).encode(null, SCHEMA_REGISTRY_SCHEMA, USER_DATA); + assertThrows(DataException.class, () -> converter.fromConnectData(testTopic, expected.schema(), genericBytes)); + } + + /** + * Test getSchema when NullPointerException is thrown. + */ + @Test + public void testGetSchema_nullObject_throwsException(){ + assertThrows(NullPointerException.class, () -> converter.getSchema(null)); + } + + + /** + * Test getSchema when an existing Avro schema is being successfully retrieved. + */ + @Test + public void getSchema_avro_succeeds(){ + + String schemaDefinition = """ + {"namespace": "com.amazonaws.services.schemaregistry.serializers.avro", + "type": "record", + "name": "payment", + "fields": [ + {"name": "id", "type": "string"}, + {"name": "id_6", "type": "double"} + ]}"""; + Schema testSchema = new Schema(schemaDefinition, DataFormat.AVRO.name(), testTopic); + + doReturn(testSchema). + when(deserializationFacade).getSchema(avroBytes); + Schema returnedSchema = converter.getSchema(avroBytes); + + assertEquals(returnedSchema.getSchemaDefinition(), testSchema.getSchemaDefinition()); + assertEquals(returnedSchema.getSchemaName(), testSchema.getSchemaName()); + assertEquals(returnedSchema.getDataFormat(), testSchema.getDataFormat()); + } + + /** + * Test getSchema when an existing JSON schema is being successfully retrieved. + */ + @Test + public void getSchema_json_succeeds() { + + String testSchemaDefinition = "{\"$id\":\"https://example.com/geographical-location.schema.json\"," + + "\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"title\":\"Longitude " + + "and Latitude Values\",\"description\":\"A geographical coordinate.\"," + + "\"required\":[\"latitude\",\"longitude\"],\"type\":\"object\"," + + "\"properties\":{\"latitude\":{\"type\":\"number\",\"minimum\":-90," + + "\"maximum\":90},\"longitude\":{\"type\":\"number\",\"minimum\":-180," + + "\"maximum\":180}},\"additionalProperties\":false}"; + + String jsonData = "{\"latitude\":48.858093,\"longitude\":2.294694}"; + byte[] jsonBytes = jsonData.getBytes(StandardCharsets.UTF_8); + Schema testSchema = new Schema(testSchemaDefinition, DataFormat.JSON.name(), testTopic); + + doReturn(testSchema). + when(deserializationFacade).getSchema(jsonBytes); + Schema returnedSchema = converter.getSchema(jsonBytes); + + assertEquals(returnedSchema.getSchemaDefinition(), testSchema.getSchemaDefinition()); + assertEquals(returnedSchema.getSchemaName(), testSchema.getSchemaName()); + assertEquals(returnedSchema.getDataFormat(), testSchema.getDataFormat()); + } + + /** + * Test getSchema when an existing Protobuf schema is being successfully retrieved. + */ + @Test + public void getSchema_protobuf_succeeds(){ + + Schema testSchema = new Schema("foo", DataFormat.PROTOBUF.name(), testTopic); + byte[] protobufBytes = "foo".getBytes(StandardCharsets.UTF_8); + + doReturn(testSchema). + when(deserializationFacade).getSchema(protobufBytes); + Schema returnedSchema = converter.getSchema(protobufBytes); + + assertEquals(returnedSchema.getSchemaDefinition(), testSchema.getSchemaDefinition()); + assertEquals(returnedSchema.getSchemaName(), testSchema.getSchemaName()); + assertEquals(returnedSchema.getDataFormat(), testSchema.getDataFormat()); + } + + /** + * Test registerSchema when NullPointerException is thrown. + */ + @Test + public void registerSchema_throwsDataException() { + assertThrows(DataException.class, () -> converter.registerSchema(null)); + } + + /** + * Test registerSchema for existing Avro schema. + */ + @Test + public void registerSchema_avro_nonExisting_succeeds() { + + String schemaDefinition = """ + {"namespace": "com.amazonaws.services.schemaregistry.serializers.avro", + "type": "record", + "name": "payment", + "fields": [ + {"name": "id", "type": "string"}, + {"name": "id_6", "type": "double"} + ]}"""; + UUID avroBytesVersionID = UUID.fromString("54182f93-257c-4a4d-9c9e-f476292039be"); + + Schema testSchema = new Schema(schemaDefinition, DataFormat.AVRO.name(), testTopic); + AWSSerializerInput input = new AWSSerializerInput(testSchema.getSchemaDefinition(), testSchema.getSchemaName(), testSchema.getDataFormat(), null); + + doReturn(avroBytesVersionID). + when(serializationFacade).getOrRegisterSchemaVersion(input); + + assertEquals(converter.registerSchema(testSchema), avroBytesVersionID); + } + + /** + * Test registerSchema for existing JSON schema. + */ + @Test + public void registerSchema_json_nonExisting_succeeds() { + String testSchemaDefinition = "{\"$id\":\"https://example.com/geographical-location.schema.json\"," + + "\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"title\":\"Longitude " + + "and Latitude Values\",\"description\":\"A geographical coordinate.\"," + + "\"required\":[\"latitude\",\"longitude\"],\"type\":\"object\"," + + "\"properties\":{\"latitude\":{\"type\":\"number\",\"minimum\":-90," + + "\"maximum\":90},\"longitude\":{\"type\":\"number\",\"minimum\":-180," + + "\"maximum\":180}},\"additionalProperties\":false}"; + UUID jsonBytesVersionID = UUID.fromString("afba13fd-7c25-4202-8904-1fab3089faf9"); + + Schema testSchema = new Schema(testSchemaDefinition, DataFormat.JSON.name(), "testJson"); + AWSSerializerInput input = new AWSSerializerInput(testSchema.getSchemaDefinition(), testSchema.getSchemaName(), testSchema.getDataFormat(), null); + + doReturn(jsonBytesVersionID). + when(serializationFacade).getOrRegisterSchemaVersion(input); + + assertEquals(converter.registerSchema(testSchema), jsonBytesVersionID); + } + + /** + * Test registerSchema for non-existing Protobuf schema. + */ + @Test + public void registerSchema_protobuf_nonExisting_succeeds() { + String testSchemaDefinition = "foo"; + UUID protobufBytesVersionID = UUID.fromString("b7b4a7f0-9c96-4e4a-a687-fb5de9ef0c63"); + + Schema testSchema = new Schema(testSchemaDefinition, DataFormat.PROTOBUF.name(), testTopic); + AWSSerializerInput input = new AWSSerializerInput(testSchema.getSchemaDefinition(), testSchema.getSchemaName(), testSchema.getDataFormat(), null); + + doReturn(protobufBytesVersionID). + when(serializationFacade).getOrRegisterSchemaVersion(input); + + assertEquals(converter.registerSchema(testSchema), protobufBytesVersionID); + } + + + /** + * To create a map of configurations w/o source region. + * + * @return a map of configurations + */ + private Map getNoSourceProperties() { + Map props = new HashMap<>(); + + props.put("TARGET_REGION", "us-west-2"); + props.put(AWSSchemaRegistryConstants.AWS_ENDPOINT, "https://test"); + props.put(AWSSchemaRegistryConstants.SCHEMA_AUTO_REGISTRATION_SETTING, true); + props.put(AWSSchemaRegistryConstants.AVRO_RECORD_TYPE, AvroRecordType.GENERIC_RECORD.getName()); + + return props; + } + + /** + * To create a map of configurations w/o source region. + * + * @return a map of configurations + */ + private Map getNoTargetProperties() { + Map props = new HashMap<>(); + + props.put("SOURCE_REGION", "us-west-2"); + props.put(AWSSchemaRegistryConstants.AWS_ENDPOINT, "https://test"); + props.put(AWSSchemaRegistryConstants.SCHEMA_AUTO_REGISTRATION_SETTING, true); + props.put(AWSSchemaRegistryConstants.AVRO_RECORD_TYPE, AvroRecordType.GENERIC_RECORD.getName()); + + return props; + } + + /** + * To create a map of configurations. + * + * @return a map of configurations + */ + private Map getTestProperties() { + Map props = new HashMap<>(); + + props.put("SOURCE_REGION", "us-west-2"); + props.put("TARGET_REGION", "us-east-1"); + props.put(AWSSchemaRegistryConstants.REGISTRY_NAME, "default-registry"); + props.put(AWSSchemaRegistryConstants.SCHEMA_NAME, "t2"); + props.put(AWSSchemaRegistryConstants.AWS_ENDPOINT, "https://test"); + props.put(AWSSchemaRegistryConstants.AWS_SRC_ENDPOINT, "https://test"); + props.put(AWSSchemaRegistryConstants.SCHEMA_AUTO_REGISTRATION_SETTING, true); + props.put(AWSSchemaRegistryConstants.AVRO_RECORD_TYPE, AvroRecordType.GENERIC_RECORD.getName()); + + return props; + } + + /** + * To create a Connect Struct record. + * + * @return a Connect Struct + */ + private Struct createStructRecord() { + org.apache.kafka.connect.data.Schema schema = SchemaBuilder.struct() + .build(); + return new Struct(schema); + } +} diff --git a/pom.xml b/pom.xml index dbfe18bb..eef56a38 100644 --- a/pom.xml +++ b/pom.xml @@ -74,6 +74,7 @@ integration-tests jsonschema-kafkaconnect-converter protobuf-kafkaconnect-converter + cross-region-replication-mm2-converter From f095ead7f5682d103a524785a0cc0043828b4a49 Mon Sep 17 00:00:00 2001 From: Jessie Chen Date: Wed, 26 Jul 2023 13:55:03 -0700 Subject: [PATCH 06/41] Modified Converter Based on First CR Feedback --- .gitignore | 3 +- common/pom.xml | 8 - .../common/AWSSchemaRegistryClient.java | 15 +- .../GlueSchemaRegistryConfiguration.java | 75 +- .../utils/AWSSchemaRegistryConstants.java | 15 +- .../GlueSchemaRegistryConfigurationTest.java | 53 +- .../.gitignore | 2 + cross-region-replication-converter/pom.xml | 229 +++++ .../CrossRegionReplicationConverter.java | 103 ++ .../src/test/avro/AvroMessage.avsc | 944 ++++++++++++++++++ .../src/test/avro/DocTestRecord.avsc | 20 + .../src/test/avro/Enum.avsc | 16 + .../src/test/avro/EnumUnion.avsc | 22 + .../src/test/avro/MultiTypeUnionMessage.avsc | 45 + .../test/avro/RepeatedTypeWithDefault.avsc | 45 + .../test/avro/RepeatedTypeWithDocFull.avsc | 96 ++ .../CrossRegionReplicationConverterTest.java | 362 +++++++ .../pom.xml | 164 --- .../CrossRegionReplicationMM2Converter.java | 161 --- ...ssRegionReplicationMM2ConverterConfig.java | 20 - .../resources/META-INF/maven/archetype.xml | 9 - .../resources/archetype-resources/pom.xml | 15 - .../src/main/java/App.java | 13 - .../src/test/java/AppTest.java | 38 - ...rossRegionReplicationMM2ConverterTest.java | 344 ------- pom.xml | 2 +- 26 files changed, 1974 insertions(+), 845 deletions(-) rename {cross-region-replication-mm2-converter => cross-region-replication-converter}/.gitignore (97%) create mode 100644 cross-region-replication-converter/pom.xml create mode 100644 cross-region-replication-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverter.java create mode 100644 cross-region-replication-converter/src/test/avro/AvroMessage.avsc create mode 100644 cross-region-replication-converter/src/test/avro/DocTestRecord.avsc create mode 100644 cross-region-replication-converter/src/test/avro/Enum.avsc create mode 100644 cross-region-replication-converter/src/test/avro/EnumUnion.avsc create mode 100644 cross-region-replication-converter/src/test/avro/MultiTypeUnionMessage.avsc create mode 100644 cross-region-replication-converter/src/test/avro/RepeatedTypeWithDefault.avsc create mode 100644 cross-region-replication-converter/src/test/avro/RepeatedTypeWithDocFull.avsc create mode 100644 cross-region-replication-converter/src/test/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverterTest.java delete mode 100644 cross-region-replication-mm2-converter/pom.xml delete mode 100644 cross-region-replication-mm2-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2Converter.java delete mode 100644 cross-region-replication-mm2-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2ConverterConfig.java delete mode 100644 cross-region-replication-mm2-converter/src/main/resources/META-INF/maven/archetype.xml delete mode 100644 cross-region-replication-mm2-converter/src/main/resources/archetype-resources/pom.xml delete mode 100644 cross-region-replication-mm2-converter/src/main/resources/archetype-resources/src/main/java/App.java delete mode 100644 cross-region-replication-mm2-converter/src/main/resources/archetype-resources/src/test/java/AppTest.java delete mode 100644 cross-region-replication-mm2-converter/src/test/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2ConverterTest.java diff --git a/.gitignore b/.gitignore index d9773299..ae8a3b4e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ **/*.project **/*.classpath **/*.factorypath -**/dependency-reduced-pom.xml \ No newline at end of file +**/dependency-reduced-pom.xml +/integration-tests/* \ No newline at end of file diff --git a/common/pom.xml b/common/pom.xml index a18e1ce0..14267498 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -65,14 +65,6 @@ software.amazon.awssdk glue - - - org.apache.maven.plugins - maven-shade-plugin - 3.2.1 - maven-plugin - - ${parent.groupId} schema-registry-build-tools diff --git a/common/src/main/java/com/amazonaws/services/schemaregistry/common/AWSSchemaRegistryClient.java b/common/src/main/java/com/amazonaws/services/schemaregistry/common/AWSSchemaRegistryClient.java index b3b48af5..949e6563 100644 --- a/common/src/main/java/com/amazonaws/services/schemaregistry/common/AWSSchemaRegistryClient.java +++ b/common/src/main/java/com/amazonaws/services/schemaregistry/common/AWSSchemaRegistryClient.java @@ -90,19 +90,18 @@ public AWSSchemaRegistryClient(@NonNull AwsCredentialsProvider credentialsProvid .retryPolicy(retryPolicy) .addExecutionInterceptor(new UserAgentRequestInterceptor()) .build(); -// UrlConnectionHttpClient.Builder urlConnectionHttpClientBuilder = UrlConnectionHttpClient.builder(); -// if (glueSchemaRegistryConfiguration.getProxyUrl() != null) { -// log.debug("Creating http client using proxy {}", glueSchemaRegistryConfiguration.getProxyUrl().toString()); -// ProxyConfiguration proxy = ProxyConfiguration.builder().endpoint(glueSchemaRegistryConfiguration.getProxyUrl()).build(); -// urlConnectionHttpClientBuilder.proxyConfiguration(proxy); -// } + UrlConnectionHttpClient.Builder urlConnectionHttpClientBuilder = UrlConnectionHttpClient.builder(); + if (glueSchemaRegistryConfiguration.getProxyUrl() != null) { + log.debug("Creating http client using proxy {}", glueSchemaRegistryConfiguration.getProxyUrl().toString()); + ProxyConfiguration proxy = ProxyConfiguration.builder().endpoint(glueSchemaRegistryConfiguration.getProxyUrl()).build(); + urlConnectionHttpClientBuilder.proxyConfiguration(proxy); + } GlueClientBuilder glueClientBuilder = GlueClient .builder() .credentialsProvider(credentialsProvider) .overrideConfiguration(overrideConfiguration) -// .httpClient(urlConnectionHttpClientBuilder.build()) - .httpClient(UrlConnectionHttpClient.builder().build()) + .httpClient(urlConnectionHttpClientBuilder.build()) .region(Region.of(glueSchemaRegistryConfiguration.getRegion())); if (glueSchemaRegistryConfiguration.getEndPoint() != null) { diff --git a/common/src/main/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfiguration.java b/common/src/main/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfiguration.java index ebc2b68c..b1c295ba 100644 --- a/common/src/main/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfiguration.java +++ b/common/src/main/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfiguration.java @@ -29,6 +29,7 @@ import software.amazon.awssdk.regions.providers.DefaultAwsRegionProviderChain; import software.amazon.awssdk.services.glue.model.Compatibility; +import java.net.URI; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -45,10 +46,11 @@ public class GlueSchemaRegistryConfiguration { private AWSSchemaRegistryConstants.COMPRESSION compressionType = AWSSchemaRegistryConstants.COMPRESSION.NONE; private String endPoint; private String region; - private String srcEndPoint; - private String srcRegion; - private String tgtEndPoint; - private String tgtRegion; + //TODO: Remove configs that are not useful non replication use-cases. + private String sourceEndPoint; + private String sourceRegion; + private String targetEndPoint; + private String targetRegion; private long timeToLiveMillis = 24 * 60 * 60 * 1000L; private int cacheSize = 200; private AvroRecordType avroRecordType; @@ -60,6 +62,7 @@ public class GlueSchemaRegistryConfiguration { private Map tags = new HashMap<>(); private Map metadata; private String secondaryDeserializer; + private URI proxyUrl; /** * Name of the application using the serializer/deserializer. @@ -90,12 +93,12 @@ private void buildConfigs(Map configs) { } private void buildSchemaRegistryConfigs(Map configs) { - validateAndSetAWSSrcRegion(configs); - validateAndSetAWSSrcEndpoint(configs); - validateAndSetAWSTgtRegion(configs); - validateAndSetAWSTgtEndpoint(configs); validateAndSetAWSRegion(configs); + validateAndSetAWSSourceRegion(configs); + validateAndSetAWSTargetRegion(configs); validateAndSetAWSEndpoint(configs); + validateAndSetAWSSourceEndpoint(configs); + validateAndSetAWSTargetEndpoint(configs); validateAndSetRegistryName(configs); validateAndSetDescription(configs); validateAndSetAvroRecordType(configs); @@ -109,6 +112,7 @@ private void buildSchemaRegistryConfigs(Map configs) { validateAndSetMetadata(configs); validateAndSetUserAgent(configs); validateAndSetSecondaryDeserializer(configs); + validateAndSetProxyUrl(configs); } private void validateAndSetSecondaryDeserializer(Map configs) { @@ -147,7 +151,7 @@ private boolean validateCompressionType(String compressionType) { if (!EnumUtils.isValidEnum(AWSSchemaRegistryConstants.COMPRESSION.class, compressionType.toUpperCase())) { String errorMessage = String.format("Invalid Compression type : %s, Accepted values are : %s", compressionType, - AWSSchemaRegistryConstants.COMPRESSION.values()); + AWSSchemaRegistryConstants.COMPRESSION.values()); throw new AWSSchemaRegistryException(errorMessage); } return true; @@ -169,17 +173,17 @@ private void validateAndSetAWSRegion(Map configs) { } } - private void validateAndSetAWSSrcRegion(Map configs) { - if (isPresent(configs, AWSSchemaRegistryConstants.AWS_SRC_REGION)) { - this.srcRegion = String.valueOf(configs.get(AWSSchemaRegistryConstants.AWS_SRC_REGION)); + private void validateAndSetAWSSourceRegion(Map configs) { + if (isPresent(configs, AWSSchemaRegistryConstants.AWS_SOURCE_REGION)) { + this.sourceRegion = String.valueOf(configs.get(AWSSchemaRegistryConstants.AWS_SOURCE_REGION)); } } - private void validateAndSetAWSTgtRegion(Map configs) { - if (isPresent(configs, AWSSchemaRegistryConstants.AWS_TGT_REGION)) { - this.tgtRegion = String.valueOf(configs.get(AWSSchemaRegistryConstants.AWS_TGT_REGION)); + private void validateAndSetAWSTargetRegion(Map configs) { + if (isPresent(configs, AWSSchemaRegistryConstants.AWS_TARGET_REGION)) { + this.targetRegion = String.valueOf(configs.get(AWSSchemaRegistryConstants.AWS_TARGET_REGION)); } else { - this.tgtRegion = String.valueOf(configs.get(AWSSchemaRegistryConstants.AWS_REGION)); + this.targetRegion = String.valueOf(configs.get(AWSSchemaRegistryConstants.AWS_REGION)); } } @@ -190,10 +194,10 @@ private void validateAndSetCompatibility(Map configs) { .toUpperCase()); if (this.compatibilitySetting == null - || this.compatibilitySetting == Compatibility.UNKNOWN_TO_SDK_VERSION) { + || this.compatibilitySetting == Compatibility.UNKNOWN_TO_SDK_VERSION) { String errorMessage = String.format("Invalid compatibility setting : %s, Accepted values are : %s", - configs.get(AWSSchemaRegistryConstants.COMPATIBILITY_SETTING), - Compatibility.knownValues()); + configs.get(AWSSchemaRegistryConstants.COMPATIBILITY_SETTING), + Compatibility.knownValues()); throw new AWSSchemaRegistryException(errorMessage); } } else { @@ -215,20 +219,31 @@ private void validateAndSetAWSEndpoint(Map configs) { } } - private void validateAndSetAWSSrcEndpoint(Map configs) { - if (isPresent(configs, AWSSchemaRegistryConstants.AWS_SRC_ENDPOINT)) { - this.srcEndPoint = String.valueOf(configs.get(AWSSchemaRegistryConstants.AWS_SRC_ENDPOINT)); + private void validateAndSetAWSSourceEndpoint(Map configs) { + if (isPresent(configs, AWSSchemaRegistryConstants.AWS_SOURCE_ENDPOINT)) { + this.sourceEndPoint = String.valueOf(configs.get(AWSSchemaRegistryConstants.AWS_SOURCE_ENDPOINT)); } } - private void validateAndSetAWSTgtEndpoint(Map configs) { - if (isPresent(configs, AWSSchemaRegistryConstants.AWS_TGT_ENDPOINT)) { - this.tgtEndPoint = String.valueOf(configs.get(AWSSchemaRegistryConstants.AWS_TGT_ENDPOINT)); + private void validateAndSetAWSTargetEndpoint(Map configs) { + if (isPresent(configs, AWSSchemaRegistryConstants.AWS_TARGET_ENDPOINT)) { + this.targetEndPoint = String.valueOf(configs.get(AWSSchemaRegistryConstants.AWS_TARGET_ENDPOINT)); } else { - this.tgtEndPoint = String.valueOf(configs.get(AWSSchemaRegistryConstants.AWS_ENDPOINT)); + this.targetEndPoint = String.valueOf(configs.get(AWSSchemaRegistryConstants.AWS_ENDPOINT)); } } + private void validateAndSetProxyUrl(Map configs) { + if (isPresent(configs, AWSSchemaRegistryConstants.PROXY_URL)) { + String value = (String) configs.get(AWSSchemaRegistryConstants.PROXY_URL); + try { + this.proxyUrl = URI.create(value); + } catch (IllegalArgumentException e) { + String message = String.format("Proxy URL property is not a valid URL: %s", value); + throw new AWSSchemaRegistryException(message, e); + } + } + } private void validateAndSetDescription(Map configs) throws AWSSchemaRegistryException { if (isPresent(configs, AWSSchemaRegistryConstants.DESCRIPTION)) { @@ -288,7 +303,7 @@ private void validateAndSetSchemaAutoRegistrationSetting(Map configs) .toString()); } else { log.info("schemaAutoRegistrationEnabled is not defined in the properties. Using the default value {}", - schemaAutoRegistrationEnabled); + schemaAutoRegistrationEnabled); } } @@ -357,9 +372,9 @@ private boolean isPresent(Map configs, private Map getMapFromPropertiesFile(Properties properties) { return new HashMap<>(properties.entrySet() - .stream() - .collect(Collectors.toMap(e -> e.getKey() - .toString(), e -> e.getValue()))); + .stream() + .collect(Collectors.toMap(e -> e.getKey() + .toString(), e -> e.getValue()))); } private String buildDescriptionFromProperties() throws AWSSchemaRegistryException { diff --git a/common/src/main/java/com/amazonaws/services/schemaregistry/utils/AWSSchemaRegistryConstants.java b/common/src/main/java/com/amazonaws/services/schemaregistry/utils/AWSSchemaRegistryConstants.java index 6aaa0772..856f3b91 100644 --- a/common/src/main/java/com/amazonaws/services/schemaregistry/utils/AWSSchemaRegistryConstants.java +++ b/common/src/main/java/com/amazonaws/services/schemaregistry/utils/AWSSchemaRegistryConstants.java @@ -18,6 +18,10 @@ import software.amazon.awssdk.services.glue.model.Compatibility; public final class AWSSchemaRegistryConstants { + /** + * Proxy URL to use while connecting to AWS endpoint. + */ + public static final String PROXY_URL = "proxyUrl"; /** * AWS endpoint to use while initializing the client for service. */ @@ -29,20 +33,19 @@ public final class AWSSchemaRegistryConstants { /** * TODO: CR_GSR: AWS source endpoint to use while initializing the client for service. */ - public static final String AWS_SRC_ENDPOINT = "source.endpoint"; + public static final String AWS_SOURCE_ENDPOINT = "source.endpoint"; /** * TODO: CR_GSR: AWS source region to use while initializing the client for service. */ - public static final String AWS_SRC_REGION = "source.region"; + public static final String AWS_SOURCE_REGION = "source.region"; /** * AWS target endpoint to use while initializing the client for service. */ - public static final String AWS_TGT_ENDPOINT = "target.endpoint"; + public static final String AWS_TARGET_ENDPOINT = "target.endpoint"; /** * AWS target region to use while initializing the client for service. */ - public static final String AWS_TGT_REGION = "target.region"; - + public static final String AWS_TARGET_REGION = "target.region"; /** * Header Version Byte. */ @@ -229,4 +232,4 @@ public enum COMPRESSION { */ ZLIB } -} \ No newline at end of file +} diff --git a/common/src/test/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfigurationTest.java b/common/src/test/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfigurationTest.java index 09d31f36..70613e06 100644 --- a/common/src/test/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfigurationTest.java +++ b/common/src/test/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfigurationTest.java @@ -275,8 +275,8 @@ public void testBuildConfig_invalidCompressionType_throwsException() { @Test public void testBuildConfig_validTags_succeeds() { Map testTags = new HashMap<>(); - testTags.put("testTagKey", "testTagValue"); - testTags.put("testTagKey2", "testTagValue2"); + testTags.put("testTagKey","testTagValue"); + testTags.put("testTagKey2","testTagValue2"); configs.put(AWSSchemaRegistryConstants.TAGS, testTags); GlueSchemaRegistryConfiguration serDeConfigs = new GlueSchemaRegistryConfiguration(configs); @@ -296,8 +296,8 @@ public void testBuildConfig_validTags_succeeds() { public void testBuildConfigWithProperties_validTags_succeeds() { Properties props = createTestProperties(); HashMap testTags = new HashMap<>(); - testTags.put("testTagKey", "testTagValue"); - testTags.put("testTagKey2", "testTagValue2"); + testTags.put("testTagKey","testTagValue"); + testTags.put("testTagKey2","testTagValue2"); props.put(AWSSchemaRegistryConstants.TAGS, testTags); GlueSchemaRegistryConfiguration serDeConfigs = new GlueSchemaRegistryConfiguration(props); @@ -388,28 +388,27 @@ public void testValidateAndSetRegistryName_withRegistryConfig_throwsException() assertEquals(expectedRegistryName, glueSchemaRegistryConfiguration.getRegistryName()); } + /** + * Tests valid proxy URL value. + */ + @Test + public void testBuildConfig_validProxyUrl_success() { + Properties props = createTestProperties(); + String proxy = "http://proxy.servers.url:8080"; + props.put(AWSSchemaRegistryConstants.PROXY_URL, proxy); + GlueSchemaRegistryConfiguration glueSchemaRegistryConfiguration = new GlueSchemaRegistryConfiguration(props); + assertEquals(URI.create(proxy), glueSchemaRegistryConfiguration.getProxyUrl()); + } -// /** -// * Tests valid proxy URL value. -// */ -// @Test -// public void testBuildConfig_validProxyUrl_success() { -// Properties props = createTestProperties(); -// String proxy = "http://proxy.servers.url:8080"; -// props.put(AWSSchemaRegistryConstants.PROXY_URL, proxy); -// GlueSchemaRegistryConfiguration glueSchemaRegistryConfiguration = new GlueSchemaRegistryConfiguration(props); -// assertEquals(URI.create(proxy), glueSchemaRegistryConfiguration.getProxyUrl()); -// } -// -// /** -// * Tests invalid proxy URL value. -// */ -// @Test -// public void testBuildConfig_invalidProxyUrl_throwsException() { -// Properties props = createTestProperties(); -// String proxy = "http:// proxy.url: 8080"; -// props.put(AWSSchemaRegistryConstants.PROXY_URL, "http:// proxy.url: 8080"); -// Exception exception = assertThrows(AWSSchemaRegistryException.class, () -> new GlueSchemaRegistryConfiguration(props)); -// assertEquals("Proxy URL property is not a valid URL: "+proxy, exception.getMessage()); -// } + /** + * Tests invalid proxy URL value. + */ + @Test + public void testBuildConfig_invalidProxyUrl_throwsException() { + Properties props = createTestProperties(); + String proxy = "http:// proxy.url: 8080"; + props.put(AWSSchemaRegistryConstants.PROXY_URL, "http:// proxy.url: 8080"); + Exception exception = assertThrows(AWSSchemaRegistryException.class, () -> new GlueSchemaRegistryConfiguration(props)); + assertEquals("Proxy URL property is not a valid URL: "+proxy, exception.getMessage()); + } } \ No newline at end of file diff --git a/cross-region-replication-mm2-converter/.gitignore b/cross-region-replication-converter/.gitignore similarity index 97% rename from cross-region-replication-mm2-converter/.gitignore rename to cross-region-replication-converter/.gitignore index 5ff6309b..8a498597 100644 --- a/cross-region-replication-mm2-converter/.gitignore +++ b/cross-region-replication-converter/.gitignore @@ -3,6 +3,8 @@ target/ !**/src/main/**/target/ !**/src/test/**/target/ +resources/ + ### IntelliJ IDEA ### .idea/modules.xml .idea/jarRepositories.xml diff --git a/cross-region-replication-converter/pom.xml b/cross-region-replication-converter/pom.xml new file mode 100644 index 00000000..2ddf9798 --- /dev/null +++ b/cross-region-replication-converter/pom.xml @@ -0,0 +1,229 @@ + + + 4.0.0 + + ${parent.groupId} + schema-registry-cross-region-kafkaconnect-converter + ${parent.version} + AWS Cross Region Glue Schema Registry Kafka Connect Schema Replication Converter + The AWS Glue Schema Registry Kafka Connect Converter enables Java developers to easily replicate + schemas across different AWS Glue Schema Registries + + https://aws.amazon.com/glue + jar + + + software.amazon.glue + schema-registry-parent + 1.1.15 + ../pom.xml + + + + + Apache License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + ossrh + https://aws.oss.sonatype.org/content/repositories/snapshots + + + ossrh + https://aws.oss.sonatype.org/service/local/staging/deploy/maven2/ + + + + scm:git:https://github.com/aws/aws-glue-schema-registry.git + scm:git:git@github.com:aws/aws-glue-schema-registry.git + https://github.com/awslabs/aws-glue-schema-registry.git + + + + + ${parent.groupId} + schema-registry-serde + ${parent.version} + + + + org.apache.maven.plugins + maven-shade-plugin + 3.2.1 + maven-plugin + + + + org.apache.kafka + connect-api + + + org.projectlombok + lombok + + + org.mockito + mockito-core + test + + + org.mockito + mockito-junit-jupiter + test + + + org.junit.vintage + junit-vintage-engine + 5.7.0 + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.junit.jupiter + junit-jupiter-params + test + + + org.junit.platform + junit-platform-commons + test + + + org.junit.jupiter + junit-jupiter-api + test + + + junit + junit + test + + + org.powermock + powermock-reflect + 2.0.7 + test + + + uk.co.jemos.podam + podam + 7.2.5.RELEASE + test + + + software.amazon.glue + schema-registry-kafkaconnect-converter + 1.1.15 + compile + + + + + + + maven-shade-plugin + 3.2.1 + + + + + package + + shade + + + + + + org.apache.avro + avro-maven-plugin + ${avro.version} + + + generate-test-sources + test-sources + + schema + + + + generate-sources + sources + + schema + + + + + true + String + + + + org.apache.maven.plugins + maven-compiler-plugin + + 15 + 15 + + + + + + + publishing + + + + org.apache.maven.plugins + maven-gpg-plugin + 1.6 + + + sign-artifacts + verify + + sign + + + + + + org.sonatype.plugins + nexus-staging-maven-plugin + 1.6.8 + true + + sonatype-nexus-staging + https://aws.oss.sonatype.org + false + + + + + + + \ No newline at end of file diff --git a/cross-region-replication-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverter.java b/cross-region-replication-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverter.java new file mode 100644 index 00000000..a15ad0ce --- /dev/null +++ b/cross-region-replication-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverter.java @@ -0,0 +1,103 @@ +package com.amazonaws.services.crossregion.schemaregistry.kafkaconnect; + +import com.amazonaws.services.schemaregistry.common.Schema; +import com.amazonaws.services.schemaregistry.common.configs.GlueSchemaRegistryConfiguration; +import com.amazonaws.services.schemaregistry.deserializers.GlueSchemaRegistryDeserializerImpl; +import com.amazonaws.services.schemaregistry.exception.AWSSchemaRegistryException; +import com.amazonaws.services.schemaregistry.serializers.GlueSchemaRegistrySerializerImpl; +import com.amazonaws.services.schemaregistry.utils.AWSSchemaRegistryConstants; + +import lombok.Data; + +import org.apache.kafka.common.errors.SerializationException; +import org.apache.kafka.connect.data.*; +import org.apache.kafka.connect.errors.DataException; +import org.apache.kafka.connect.storage.Converter; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; + +import java.util.HashMap; +import java.util.Map; + +@Data +public class CrossRegionReplicationConverter implements Converter { + + private AwsCredentialsProvider credentialsProvider; + private GlueSchemaRegistryDeserializerImpl deserializer; + private GlueSchemaRegistrySerializerImpl serializer; + private boolean isKey; + + /** + * Constructor used by Kafka Connect user. + */ + public CrossRegionReplicationConverter(){}; + + /** + * Constructor accepting AWSCredentialsProvider. + * + * @param credentialsProvider AWSCredentialsProvider instance. + */ + public CrossRegionReplicationConverter( + AwsCredentialsProvider credentialsProvider, + GlueSchemaRegistryDeserializerImpl deserializerImpl, + GlueSchemaRegistrySerializerImpl serializerImpl) { + + this.credentialsProvider = credentialsProvider; + this.deserializer = deserializerImpl; + this.serializer = serializerImpl; + } + + /** + * Configure the Schema Replication Converter. + * @param configs configuration elements for the converter + * @param isKey true if key, false otherwise + */ + @Override + public void configure(Map configs, boolean isKey) { + this.isKey = isKey; + //TODO: Support credentialProvider passed on by the user + credentialsProvider = DefaultCredentialsProvider.builder().build(); + + // Put the source and target regions into configurations respectively + Map sourceConfigs = new HashMap<>(configs); + Map targetConfigs = new HashMap<>(configs); + + if (configs.get(AWSSchemaRegistryConstants.AWS_SOURCE_REGION) == null){ + throw new DataException("Source Region is not provided."); + } + else if (configs.get(AWSSchemaRegistryConstants.AWS_TARGET_REGION) == null && configs.get(AWSSchemaRegistryConstants.AWS_REGION) == null){ + throw new DataException("Target Region is not provided."); + } + + sourceConfigs.put(AWSSchemaRegistryConstants.AWS_REGION, configs.get(AWSSchemaRegistryConstants.AWS_SOURCE_REGION)); + targetConfigs.put(AWSSchemaRegistryConstants.AWS_REGION, configs.get(AWSSchemaRegistryConstants.AWS_TARGET_REGION)); + + serializer = new GlueSchemaRegistrySerializerImpl(credentialsProvider, new GlueSchemaRegistryConfiguration(targetConfigs)); + deserializer = new GlueSchemaRegistryDeserializerImpl(credentialsProvider, new GlueSchemaRegistryConfiguration(sourceConfigs)); + } + + @Override + public byte[] fromConnectData(String topic, org.apache.kafka.connect.data.Schema schema, Object value) { + if (value == null) return null; + byte[] bytes = (byte[]) value; + + try { + byte[] deserializedBytes = deserializer.getData(bytes); + Schema deserializedSchema = deserializer.getSchema(bytes); + //The registry is decided by the configuration in the target region , schema name is the same as the source region + return serializer.encode(topic, deserializedSchema, deserializedBytes); + + } catch (SerializationException | AWSSchemaRegistryException e){ + throw new DataException("Converting Kafka Connect data to byte[] failed due to serialization/deserialization error: ", e); + } + } + + /** + * This method is not intended to be used for the CrossRegionReplicationConverter given it is integrated with a source connector + * + */ + @Override + public SchemaAndValue toConnectData(String topic, byte[] value) { + throw new UnsupportedOperationException("This method is not supported"); + } +} \ No newline at end of file diff --git a/cross-region-replication-converter/src/test/avro/AvroMessage.avsc b/cross-region-replication-converter/src/test/avro/AvroMessage.avsc new file mode 100644 index 00000000..4a784cd6 --- /dev/null +++ b/cross-region-replication-converter/src/test/avro/AvroMessage.avsc @@ -0,0 +1,944 @@ +{ + "type" : "record", + "name" : "AvroMessage", + "namespace" : "io.test.avro.core", + "fields" : [ { + "name" : "payload", + "type" : [ "null", { + "type" : "record", + "name" : "Event", + "namespace" : "io.test.trade.v1.order", + "fields" : [ { + "name" : "state", + "type" : { + "type" : "record", + "name" : "State", + "fields" : [ { + "name" : "orderId", + "type" : { + "type" : "record", + "name" : "Id", + "namespace" : "io.test.trade.v1", + "doc" : "Id of an order or position.", + "fields" : [ { + "name" : "source", + "type" : { + "type" : "enum", + "name" : "Source", + "symbols" : [ "ORDER_SERVER", "CLIENT", "UNIVERSE", "L2", "L2_CHAIN", "EXCHANGE", "UNIVERSE_ATTR", "UNDEFINED" ] + } + }, { + "name" : "value", + "type" : { + "type" : "string", + "avro.java.string" : "String" + } + } ] + }, + "doc" : "The ID to reference this order." + }, { + "name" : "accountId", + "type" : { + "type" : "record", + "name" : "Id", + "namespace" : "io.test.trade.v1.common.account", + "fields" : [ { + "name" : "value", + "type" : { + "type" : "string", + "avro.java.string" : "String" + } + } ] + }, + "doc" : "The account that the order is associated to." + }, { + "name" : "allocation", + "type" : { + "type" : "record", + "name" : "Allocation", + "namespace" : "io.test.trade.v1.common", + "fields" : [ { + "name" : "direction", + "type" : { + "type" : "enum", + "name" : "Direction", + "symbols" : [ "BUY", "SELL" ] + } + }, { + "name" : "size", + "type" : { + "type" : "record", + "name" : "Size", + "fields" : [ { + "name" : "value", + "type" : "double" + } ] + } + }, { + "name" : "displaySize", + "type" : "Size", + "doc" : "Size used for presentation and external reporting purposes. Note: Margining, Profit/Loss calculation, exposure, etc., should multiply this size with lotSize for calculations." + }, { + "name" : "displaySizeUnit", + "type" : { + "type" : "enum", + "name" : "DisplaySizeUnit", + "symbols" : [ "SHARES", "CONTRACTS", "AMOUNT_PER_POINTS" ] + }, + "doc" : "Define how the displaySize is expressed." + }, { + "name" : "lotSize", + "type" : "double", + "doc" : "Defined on the instrument. Clients on spread-bet accounts use lot size of 1, while CFD and StockBroking clients use lot size configured on the instrument. Dealers can book orders on any client account with a lot size of 1. Hedge accounts have different rules - they are generally booked in lots with lotSize 1, except equities (and equity options)" + }, { + "name" : "currency", + "type" : { + "type" : "record", + "name" : "ISOCurrency", + "fields" : [ { + "name" : "value", + "type" : { + "type" : "string", + "avro.java.string" : "String" + } + } ] + }, + "doc" : "Currency of the order/position" + } ] + }, + "doc" : "Details of the allocation of this order, size/amount, currency and direction." + }, { + "name" : "instrument", + "type" : { + "type" : "record", + "name" : "Instrument", + "namespace" : "io.test.trade.v1.common", + "fields" : [ { + "name" : "bookingCodeType", + "type" : { + "type" : "enum", + "name" : "BookingCodeType", + "symbols" : [ "EPIC", "ISIN_AND_CURRENCY" ] + }, + "doc" : "Indicates if the booking was made using an ISIN or EPIC. If EPIC, unique instrument identifier is the EPIC. If ISIN_AND_CURRENCY, the unique identifier is ISIN and CURRENCY. In this last case EPIC is also set for internal usage." + }, { + "name" : "epic", + "type" : { + "type" : "string", + "avro.java.string" : "String" + }, + "doc" : "This field is populated for all booking operations and it represents the id of the instrument on which the booking was made. Note: This field will probably be made optional when contract-ids are introduced" + }, { + "name" : "isin", + "type" : [ "null", { + "type" : "string", + "avro.java.string" : "String" + } ], + "doc" : "ISIN is populated for stock-broking deals", + "default" : null + }, { + "name" : "level", + "type" : { + "type" : "record", + "name" : "Level", + "fields" : [ { + "name" : "value", + "type" : "double" + } ] + }, + "doc" : "The level at which the position is booked. This level is used for pnl, margining, auto-hedge, exposure calculation, and other such purposes. This level should always be displayLevel multiplied by scaling factor, however, there are a few UV based flows where that constraint doesn't hold." + }, { + "name" : "displayLevel", + "type" : "Level", + "doc" : "The level displayed in the front-end. Note: PnL calculation, auto-hedge and other such operations should multiply by scaling factor." + }, { + "name" : "scalingFactor", + "type" : "double", + "doc" : "The scaling factor used for booking this position." + }, { + "name" : "instrumentType", + "type" : [ "null", { + "type" : "enum", + "name" : "InstrumentType", + "symbols" : [ "SHARES", "CURRENCIES", "INDICES", "BINARY", "FAST_BINARY", "COMMODITIES", "RATES", "OPTIONS_SHARES", "OPTIONS_CURRENCIES", "OPTIONS_INDICES", "OPTIONS_COMMODITIES", "OPTIONS_RATES", "BUNGEE_SHARES", "BUNGEE_CURRENCIES", "BUNGEE_INDICES", "BUNGEE_COMMODITIES", "BUNGEE_RATES", "CAPPED_BUNGEE", "TEST_MARKETS", "SPORTS", "SECTORS" ] + } ], + "doc" : "Type of the instrument on which the booking is made. This field is made optional to accommodate positions missing instrument type for some reason (very old positions, UV based legacy flows, etc)", + "default" : null + }, { + "name" : "marketName", + "type" : [ "null", { + "type" : "string", + "avro.java.string" : "String" + } ], + "default" : null + }, { + "name" : "scalingFactorOnInstrumentIfDifferent", + "type" : [ "null", "double" ], + "doc" : "If the scaling factor on the instrument is different from the scaling factor used to book this position then this field carries this new scaling factor. This field is used by a trade anomaly report (maintained by XCON)", + "default" : null + } ] + }, + "doc" : "Details on booked level and instrument information" + }, { + "name" : "timestamps", + "type" : { + "type" : "record", + "name" : "Timestamps", + "namespace" : "io.test.trade.v1.common", + "doc" : "See http://test.io/wiki/Position+History+Tactical+Fixes", + "fields" : [ { + "name" : "created", + "type" : { + "type" : "record", + "name" : "UTCTimestamp", + "fields" : [ { + "name" : "value", + "type" : "long" + } ] + }, + "doc" : "Timestamp of the trade's creation time" + }, { + "name" : "lastModified", + "type" : [ "null", "UTCTimestamp" ], + "doc" : "Timestamp of the trade's modification time. For RESTATE, this field indicates the timestamp of the restate", + "default" : null + }, { + "name" : "lastEdited", + "type" : [ "null", "UTCTimestamp" ], + "doc" : "Applicable only for RESTATES and specifies the timestamp at which this trade was last edited", + "default" : null + }, { + "name" : "margin", + "type" : [ "null", "UTCTimestamp" ], + "doc" : "Timestamp of the trade's margin time.", + "default" : null + } ] + }, + "doc" : "Timestamps of when the order was created, modified or last edited." + }, { + "name" : "isForceOpen", + "type" : "boolean", + "doc" : "Upon full fill, should this order close positions existing in opposite direction?", + "default" : false + }, { + "name" : "attachedStop", + "type" : [ "null", { + "type" : "record", + "name" : "Stop", + "namespace" : "io.test.trade.v1.common.contingent", + "fields" : [ { + "name" : "value", + "type" : "double", + "doc" : "Stop value can be expressed either as a Level or Distance. Use this field in conjunction with valueType" + }, { + "name" : "valueType", + "type" : { + "type" : "enum", + "name" : "StopValueType", + "symbols" : [ "DISTANCE", "LEVEL" ] + }, + "doc" : "Represents the unit in which the stop value is expressed" + }, { + "name" : "isGuaranteed", + "type" : "boolean", + "default" : false + }, { + "name" : "lrPremium", + "type" : [ "null", "double" ], + "doc" : "This field represents a multiplier to be applied to the trade's size to derive a limited risk fee (LR Fee). The LR fee is a monetary amount and is expressed in the currency of the order.", + "default" : null + }, { + "name" : "trailingStop", + "type" : [ "null", { + "type" : "record", + "name" : "TrailingStop", + "fields" : [ { + "name" : "distance", + "type" : "double", + "default" : 0.0 + }, { + "name" : "increment", + "type" : "double", + "default" : 0.0 + } ] + } ], + "default" : null + }, { + "name" : "orderIds", + "type" : [ "null", { + "type" : "array", + "items" : "io.test.trade.v1.Id" + } ], + "doc" : "Ids identifying this stop.", + "default" : null + } ] + } ], + "doc" : "An attached Stop is a 'stop-loss' order; an instruction to close a position when a certain level is breached, to minimize loss.", + "default" : null + }, { + "name" : "attachedLimit", + "type" : [ "null", { + "type" : "record", + "name" : "Limit", + "namespace" : "io.test.trade.v1.common.contingent", + "fields" : [ { + "name" : "value", + "type" : "double", + "doc" : "Limit value can be expressed either as a Level or Distance. Use this field in conjunction with valueType" + }, { + "name" : "valueType", + "type" : { + "type" : "enum", + "name" : "LimitValueType", + "symbols" : [ "DISTANCE", "LEVEL" ] + }, + "doc" : "Represents the unit in which the limit value is expressed" + }, { + "name" : "orderIds", + "type" : [ "null", { + "type" : "array", + "items" : "io.test.trade.v1.Id" + } ], + "doc" : "Ids identifying this limit.", + "default" : null + } ] + } ], + "doc" : "An attached limit is a 'profit-limit' order; an instruction to close a position when a certain level is breached, to guarantee profit.", + "default" : null + }, { + "name" : "legacyInfo", + "type" : { + "type" : "record", + "name" : "LegacyInfo", + "namespace" : "io.test.trade.v1.common", + "doc" : "Legacy information for retro compatibility purpose. Should not be used in any new service.", + "fields" : [ { + "name" : "uvCurrency", + "type" : { + "type" : "string", + "avro.java.string" : "String" + }, + "doc" : "deprecated field", + "default" : "" + }, { + "name" : "marketCommodity", + "type" : { + "type" : "string", + "avro.java.string" : "String" + }, + "doc" : "deprecated field", + "default" : "" + }, { + "name" : "prompt", + "type" : [ "null", { + "type" : "string", + "avro.java.string" : "String" + } ], + "doc" : "Deprecated field. Represents the period at which the instrument expires. This field is also referred to as 'period' in some legacy messages", + "default" : null + }, { + "name" : "submitOrderType", + "type" : [ "null", { + "type" : "enum", + "name" : "SubmitOrderType", + "symbols" : [ "UNCONTROLLED_OPEN", "UNCONTROLLED_CLOSE", "CONTROLLED_OPEN", "CONTROLLED_CLOSE", "UNCONTROLLED_OPEN_WITH_STOP", "UNCONTROLLED_CLOSE_WITH_STOP", "MANUAL_POSITION_DELETE", "OPEN_WITH_EXPIRY_STOP" ] + } ], + "doc" : "deprecated field", + "default" : null + }, { + "name" : "requestType", + "type" : [ "null", { + "type" : "enum", + "name" : "RequestType", + "symbols" : [ "AMEND_ORDER", "FINANCE_ORDER", "CFD_ORDER", "PHYSICAL", "UNATTACHED_LIMIT_ORDER", "UNATTACHED_STOP_ORDER", "UNATTACHED_ORDER_DELETE", "UNATTACHED_ORDER_FILL", "UNATTACHED_BUFFER_LIMITS", "UNATTACHED_BUFFER_LIMITS_DELETE", "MARKET_ORDER" ] + } ], + "doc" : "deprecated field", + "default" : null + }, { + "name" : "exchangeRateEpic", + "type" : [ "null", { + "type" : "string", + "avro.java.string" : "String" + } ], + "doc" : "Deprecated field. This field represents the fx rate epic mapped to the trade's currency.", + "default" : null + } ] + }, + "doc" : "Legacy attributes, a hang over from UV, eg market commod." + }, { + "name" : "channel", + "type" : { + "type" : "record", + "name" : "Channel", + "namespace" : "io.test.trade.v1.common", + "fields" : [ { + "name" : "value", + "type" : { + "type" : "string", + "avro.java.string" : "String" + } + } ] + }, + "doc" : "The channel though which the order was placed , eg. WEB, L2. This will not change if the order is amended. For this, see channel in change info." + }, { + "name" : "expiry", + "type" : { + "type" : "record", + "name" : "Expiry", + "namespace" : "io.test.trade.v1.order.common", + "fields" : [ { + "name" : "timeInForce", + "type" : { + "type" : "enum", + "name" : "TimeInForce", + "namespace" : "io.test.trade.v1.common", + "symbols" : [ "DAY", "GOOD_TILL_CANCEL", "AT_THE_OPENING", "IMMEDIATE_OR_CANCEL", "FILL_OR_KILL", "GOOD_TILL_CROSSING", "GOOD_TILL_DATE", "AT_THE_CLOSE", "DAY_ALL_SESSIONS" ] + } + }, { + "name" : "goodTillDateTimestamp", + "type" : [ "null", "io.test.trade.v1.common.UTCTimestamp" ], + "default" : null + } ] + }, + "doc" : "The date/time of when this order expires." + }, { + "name" : "accountAttributes", + "type" : { + "type" : "record", + "name" : "Attributes", + "namespace" : "io.test.trade.v1.common.account", + "fields" : [ { + "name" : "accountProduct", + "type" : [ "null", { + "type" : "enum", + "name" : "Product", + "symbols" : [ "SPREAD_BET", "CFD", "PHYSICAL" ] + } ], + "default" : null + }, { + "name" : "locale", + "type" : { + "type" : "string", + "avro.java.string" : "String" + }, + "doc" : "Represents the locale of the account, such as en_gb. It is highly likely that this field will be removed in the future." + }, { + "name" : "powerOfAttorneyName", + "type" : [ "null", { + "type" : "string", + "avro.java.string" : "String" + } ], + "doc" : "The POA name specified on the order that booked this position.", + "default" : null + }, { + "name" : "convertOnCloseCurrency", + "type" : [ "null", "io.test.trade.v1.common.ISOCurrency" ], + "doc" : "Retrieved from the convert on close information stored on this position. Note: Only populated if convert-on-close is applicable for this position.", + "default" : null + }, { + "name" : "currency", + "type" : [ "null", "io.test.trade.v1.common.ISOCurrency" ], + "doc" : "Account's currency in ISO format", + "default" : null + } ] + }, + "doc" : "Account attributes such as convert on close details and Power Of Attorney name." + }, { + "name" : "dmaOrder", + "type" : [ "null", { + "type" : "record", + "name" : "Order", + "namespace" : "io.test.trade.v1.common.dma", + "fields" : [ { + "name" : "pseudoPositionId", + "type" : [ "null", "io.test.trade.v1.Id" ], + "doc" : "Represents ID of position created by a partially filled DMA order.", + "default" : null + }, { + "name" : "orderType", + "type" : { + "type" : "enum", + "name" : "OrderType", + "symbols" : [ "MARKET", "LIMIT", "STOP", "STOP_LIMIT", "MARKET_ON_CLOSE", "WITH_OR_WITHOUT", "LIMIT_OR_BETTER", "LIMIT_WITH_OR_WITHOUT", "ON_BASIS", "ON_CLOSE", "LIMIT_ON_CLOSE", "FOREX_MARKET", "PREVIOUSLY_QUOTED", "PREVIOUSLY_INDICATED", "FOREX_LIMIT", "PEGGED", "TRADE_REPORT", "FAST_BINARY", "UNKNOWN" ] + } + }, { + "name" : "timeInForce", + "type" : "io.test.trade.v1.common.TimeInForce" + }, { + "name" : "originalSize", + "type" : "io.test.trade.v1.common.Size", + "doc" : "The original size on a DMA working order. This is in display terms and does not include lotSize" + }, { + "name" : "fills", + "type" : [ "null", { + "type" : "record", + "name" : "Fills", + "fields" : [ { + "name" : "aggregatedFill", + "type" : [ "null", { + "type" : "array", + "items" : { + "type" : "record", + "name" : "AggregatedFill", + "doc" : "Aggregated information of fills received per hedge account", + "fields" : [ { + "name" : "hedgeAccountId", + "type" : "io.test.trade.v1.common.account.Id" + }, { + "name" : "averageLevel", + "type" : "io.test.trade.v1.common.Level", + "doc" : "A volume-weighted-average level of all fills originating from this hedge account" + }, { + "name" : "totalSize", + "type" : "io.test.trade.v1.common.Size", + "doc" : "Total size of all fills received from this hedge account" + }, { + "name" : "averageExchangeFee", + "type" : "double", + "doc" : "The fee is expressed in account's currency." + } ] + } + } ], + "doc" : "A collection of DMA fills aggregated per hedge account", + "default" : null + }, { + "name" : "updateType", + "type" : { + "type" : "enum", + "name" : "FillsUpdateType", + "symbols" : [ "ADD", "COPY", "DELETE_ALL" ] + }, + "default" : "COPY" + }, { + "name" : "nextWorkingOrderId", + "type" : [ "null", "io.test.trade.v1.Id" ], + "default" : null + } ] + } ], + "default" : null + }, { + "name" : "isDMAInteractable", + "type" : "boolean", + "default" : true + }, { + "name" : "executionPricePreference", + "type" : [ "null", { + "type" : "enum", + "name" : "ExecType", + "symbols" : [ "ASK", "BID" ] + } ], + "doc" : "Called executionInstruction in current schema. This field represents DMA FX Stop Order execution price preference, could be either empty, ASK(0) or BID(9) and indicates whether one's order gets executed closer to the Bid or Ask side compared to the specified order direction.", + "default" : null + }, { + "name" : "uvOrderId", + "type" : [ "null", "io.test.trade.v1.Id" ], + "default" : null + }, { + "name" : "isPseudoPosition", + "type" : "boolean", + "doc" : "Is this position a partial fill for a DMA order?", + "default" : false + }, { + "name" : "nextPseudoPositionId", + "type" : [ "null", "io.test.trade.v1.Id" ], + "doc" : "In a DMA amend scenario, the id of a pseudo-position changes and this field indicates the new pseudo position id.", + "default" : null + } ] + } ], + "doc" : "DMA order attributes such as order type", + "default" : null + }, { + "name" : "additionalIds", + "type" : [ "null", { + "type" : "array", + "items" : "io.test.trade.v1.Id" + } ], + "doc" : "Additional ids used to reference this order.", + "default" : null + }, { + "name" : "stockBrokingAttributes", + "type" : [ "null", { + "type" : "record", + "name" : "Attributes", + "namespace" : "io.test.trade.v1.order.stockbroking", + "fields" : [ { + "name" : "settlementDate", + "type" : [ "null", "io.test.trade.v1.common.UTCTimestamp" ], + "default" : null + }, { + "name" : "tradeDate", + "type" : [ "null", "io.test.trade.v1.common.UTCTimestamp" ], + "default" : null + }, { + "name" : "charges", + "type" : [ "null", { + "type" : "record", + "name" : "Charges", + "fields" : [ { + "name" : "commission", + "type" : { + "type" : "record", + "name" : "Money", + "namespace" : "io.test.trade.v1.common", + "fields" : [ { + "name" : "currency", + "type" : "ISOCurrency" + }, { + "name" : "amount", + "type" : "double" + } ] + } + }, { + "name" : "physicalCharges", + "type" : [ "null", { + "type" : "array", + "items" : { + "type" : "record", + "name" : "Charge", + "fields" : [ { + "name" : "code", + "type" : { + "type" : "string", + "avro.java.string" : "String" + }, + "default" : "" + }, { + "name" : "name", + "type" : { + "type" : "string", + "avro.java.string" : "String" + }, + "default" : "" + }, { + "name" : "amount", + "type" : "io.test.trade.v1.common.Money" + }, { + "name" : "rate", + "type" : "double" + }, { + "name" : "threshold", + "type" : "double" + } ] + } + } ], + "default" : null + } ] + } ], + "default" : null + }, { + "name" : "reservedCash", + "type" : [ "null", "io.test.trade.v1.common.Money" ], + "default" : null + } ] + } ], + "doc" : "Stock Broking specific attributes such as settlement date and trade date.", + "default" : null + }, { + "name" : "commissionInstructions", + "type" : [ "null", { + "type" : "record", + "name" : "Instructions", + "namespace" : "io.test.trade.v1.common.commission", + "fields" : [ { + "name" : "bypasses", + "type" : [ "null", { + "type" : "record", + "name" : "Bypasses", + "fields" : [ { + "name" : "legacyCRPremium", + "type" : "boolean", + "doc" : "For guaranteed stops, should bypass reserving LR Premium fee", + "default" : false + }, { + "name" : "commission", + "type" : "boolean", + "doc" : "Should commission be bypassed", + "default" : false + }, { + "name" : "charges", + "type" : "boolean", + "doc" : "Should charges be bypassed", + "default" : false + }, { + "name" : "consideration", + "type" : "boolean", + "doc" : "Should consideration based fee be bypassed", + "default" : false + } ] + } ], + "default" : null + }, { + "name" : "overrideType", + "type" : [ "null", { + "type" : "enum", + "name" : "OverrideType", + "doc" : "AMOUNT: This type used when dealer wants to fixed Commission charge in Client's base currency. When the Amount value is Zero, no commission will be charged.\n. WEB_RATES: This type is used when dealer wants the client's web rates to be used. Otherwise an input from IG Dealer will cause the phone rates to be used.\nPERCENT: This type is used when dealer wants to supply the percentage rate to be used for commission calculation", + "symbols" : [ "AMOUNT", "PERCENT", "WEB_RATES" ] + } ], + "default" : null + }, { + "name" : "rate", + "type" : [ "null", "double" ], + "default" : null + }, { + "name" : "comment", + "type" : [ "null", { + "type" : "string", + "avro.java.string" : "String" + } ], + "default" : null + } ] + } ], + "doc" : "instructions of which changes to bypass or override.", + "default" : null + }, { + "name" : "additionalLeg", + "type" : [ "null", { + "type" : "record", + "name" : "AdditionalLeg", + "namespace" : "io.test.trade.v1.order.common", + "fields" : [ { + "name" : "instrument", + "type" : "io.test.trade.v1.common.Instrument" + }, { + "name" : "marketCommodity", + "type" : [ "null", { + "type" : "string", + "avro.java.string" : "String" + } ], + "default" : null + }, { + "name" : "direction", + "type" : "io.test.trade.v1.common.Direction" + }, { + "name" : "averagePrice", + "type" : [ "null", "io.test.trade.v1.common.Level" ], + "default" : null + } ] + } ], + "doc" : "DMA orders on hedge accounts can optionally have an additional leg (instrument) to book the same order.", + "default" : null + }, { + "name" : "profileData", + "type" : [ "null", { + "type" : "record", + "name" : "ProfileData", + "fields" : [ { + "name" : "parentAccountId", + "type" : "io.test.trade.v1.common.account.Id", + "doc" : "For Profile orders this is the reference to the parent account." + }, { + "name" : "parentOrderId", + "type" : "io.test.trade.v1.Id", + "doc" : "For profile orders this will be the parent order id." + } ] + } ], + "doc" : "Profile orders where the order is processed on the parent account and booked against the child accounts.", + "default" : null + }, { + "name" : "lockState", + "type" : [ "null", { + "type" : "record", + "name" : "State", + "namespace" : "io.test.trade.v1.common.lock", + "fields" : [ { + "name" : "idOfLockingDMAOrder", + "type" : "io.test.trade.v1.Id", + "doc" : "Contains id of a DMA order that has locked this position, presumably for explicitly closing this position" + }, { + "name" : "holder", + "type" : [ "null", { + "type" : "string", + "avro.java.string" : "String" + } ], + "default" : null + }, { + "name" : "source", + "type" : [ "null", { + "type" : "enum", + "name" : "Source", + "symbols" : [ "COM", "DMA", "STOP_MONITOR" ] + } ], + "default" : null + }, { + "name" : "stopMonitorState", + "type" : [ "null", { + "type" : "enum", + "name" : "StopMonitorState", + "symbols" : [ "COM", "DMA", "STOP_MONITOR" ] + } ], + "default" : null + } ] + } ], + "doc" : "Indicates if the order is locked and they type of lock.", + "default" : null + }, { + "name" : "narrative", + "type" : [ "null", { + "type" : "string", + "avro.java.string" : "String" + } ], + "doc" : "Free text for reference. Not used in processing.", + "default" : null + } ] + } + }, { + "name" : "changeInfo", + "type" : { + "type" : "record", + "name" : "Info", + "namespace" : "io.test.trade.v1.order.change", + "fields" : [ { + "name" : "action", + "type" : { + "type" : "enum", + "name" : "Action", + "namespace" : "io.test.trade.v1.common.change", + "symbols" : [ "UPDATE", "DELETE", "NEW", "RESTATE" ] + } + }, { + "name" : "channel", + "type" : [ "null", "io.test.trade.v1.common.Channel" ], + "default" : null + }, { + "name" : "attachedStop", + "type" : [ "null", { + "type" : "record", + "name" : "Stop", + "namespace" : "io.test.trade.v1.common.change.attached", + "fields" : [ { + "name" : "action", + "type" : { + "type" : "enum", + "name" : "Action", + "symbols" : [ "NEW", "DELETED", "UPDATED" ] + } + }, { + "name" : "distance", + "type" : "double" + }, { + "name" : "trailingStop", + "type" : [ "null", { + "type" : "record", + "name" : "TrailingStop", + "fields" : [ { + "name" : "action", + "type" : "Action" + }, { + "name" : "distance", + "type" : "double" + }, { + "name" : "increment", + "type" : "double" + } ] + } ], + "default" : null + } ] + } ], + "default" : null + }, { + "name" : "attachedLimit", + "type" : [ "null", { + "type" : "record", + "name" : "Limit", + "namespace" : "io.test.trade.v1.common.change.attached", + "fields" : [ { + "name" : "action", + "type" : "Action" + }, { + "name" : "distance", + "type" : "double" + } ] + } ], + "default" : null + }, { + "name" : "transactOrderReference", + "type" : [ "null", "io.test.trade.v1.Id" ], + "default" : null + }, { + "name" : "transactTimestamp", + "type" : [ "null", "io.test.trade.v1.common.UTCTimestamp" ], + "default" : null + } ] + } + }, { + "name" : "transaction", + "type" : [ "null", { + "type" : "record", + "name" : "Info", + "namespace" : "io.test.trade.v1.common.transaction", + "fields" : [ { + "name" : "id", + "type" : [ "null", { + "type" : "string", + "avro.java.string" : "String" + } ], + "default" : null + }, { + "name" : "group", + "type" : [ "null", { + "type" : "record", + "name" : "Group", + "fields" : [ { + "name" : "size", + "type" : "int" + }, { + "name" : "messageIndex", + "type" : "int" + } ] + } ], + "default" : null + } ] + } ], + "default" : null + } ] + } ], + "default" : null + }, { + "name" : "properties", + "type" : [ "null", { + "type" : "map", + "values" : { + "type" : "string", + "avro.java.string" : "String" + }, + "avro.java.string" : "String" + } ], + "default" : null + }, { + "name" : "uuid", + "type" : [ "null", { + "type" : "string", + "avro.java.string" : "String" + } ], + "default" : null + }, { + "name" : "clientId", + "type" : [ "null", { + "type" : "string", + "avro.java.string" : "String" + } ], + "default" : null + }, { + "name" : "partitionKey", + "type" : [ "null", { + "type" : "string", + "avro.java.string" : "String" + } ], + "default" : null + }, { + "name" : "correlationId", + "type" : [ "null", { + "type" : "string", + "avro.java.string" : "String" + } ], + "default" : null + }, { + "name" : "clusterId", + "type" : [ "null", { + "type" : "string", + "avro.java.string" : "String" + } ], + "default" : null + } ] +} \ No newline at end of file diff --git a/cross-region-replication-converter/src/test/avro/DocTestRecord.avsc b/cross-region-replication-converter/src/test/avro/DocTestRecord.avsc new file mode 100644 index 00000000..b83348ef --- /dev/null +++ b/cross-region-replication-converter/src/test/avro/DocTestRecord.avsc @@ -0,0 +1,20 @@ +{ + "type" : "record", + "name" : "DocTestRecord", + "namespace" : "io.test.avro.doc", + "doc" : "Some record document.", + "fields" : [ { + "name" : "obj", + "type" : { + "type" : "record", + "name" : "DocTestRecord1", + "doc" : "Some nested record document.", + "fields" : [ { + "name" : "data", + "type" : "string", + "doc" : "Some nested record field document." + } ] + }, + "doc" : "Some field document." + } ] +} \ No newline at end of file diff --git a/cross-region-replication-converter/src/test/avro/Enum.avsc b/cross-region-replication-converter/src/test/avro/Enum.avsc new file mode 100644 index 00000000..4d24ad60 --- /dev/null +++ b/cross-region-replication-converter/src/test/avro/Enum.avsc @@ -0,0 +1,16 @@ +{ + "namespace": "foo.bar", + "type": "record", + "name": "EnumTest", + "fields": [ + {"name": "testkey", "type": "string"}, + { + "name": "kind", + "type": { + "name": "Kind", + "type": "enum", + "symbols" : ["ONE", "TWO", "THREE"] + } + } + ] +} \ No newline at end of file diff --git a/cross-region-replication-converter/src/test/avro/EnumUnion.avsc b/cross-region-replication-converter/src/test/avro/EnumUnion.avsc new file mode 100644 index 00000000..7e90cd0a --- /dev/null +++ b/cross-region-replication-converter/src/test/avro/EnumUnion.avsc @@ -0,0 +1,22 @@ +{ + "type": "record", + "name": "EnumUnion", + "namespace": "com.connect.avro", + "fields": [ + { + "name": "userType", + "type": [ + "null", + { + "type": "enum", + "name": "UserType", + "symbols": [ + "ANONYMOUS", + "REGISTERED" + ] + } + ], + "default": null + } + ] +} \ No newline at end of file diff --git a/cross-region-replication-converter/src/test/avro/MultiTypeUnionMessage.avsc b/cross-region-replication-converter/src/test/avro/MultiTypeUnionMessage.avsc new file mode 100644 index 00000000..35aa90b7 --- /dev/null +++ b/cross-region-replication-converter/src/test/avro/MultiTypeUnionMessage.avsc @@ -0,0 +1,45 @@ +{ + "type": "record", + "name": "MultiTypeUnionMessage", + "namespace": "io.test.avro.union", + "fields": [ + { + "name": "CompositeRecord", + "type": [ + "null", + { + "type": "record", + "name": "FirstOption", + "fields": [ + { + "name": "x", + "type": "string" + }, + { + "name": "y", + "type": "long" + } + ] + }, + { + "type": "record", + "name": "SecondOption", + "fields": [ + { + "name": "a", + "type": "string" + }, + { + "name": "b", + "type": "long" + } + ] + }, + { + "type": "array", + "items": "string" + } + ] + } + ] +} \ No newline at end of file diff --git a/cross-region-replication-converter/src/test/avro/RepeatedTypeWithDefault.avsc b/cross-region-replication-converter/src/test/avro/RepeatedTypeWithDefault.avsc new file mode 100644 index 00000000..12b80326 --- /dev/null +++ b/cross-region-replication-converter/src/test/avro/RepeatedTypeWithDefault.avsc @@ -0,0 +1,45 @@ +{ + "name": "RepeatedTypeWithDefault", + "namespace": "com.rr.avro.test", + "type": "record", + "fields": [ + { + "name": "stringField", + "type": "string", + "default": "field's default" + }, + { + "name": "anotherStringField", + "type": "string" + }, + { + "name": "enumField", + "default": "ONE", + "type": { + "name": "Kind", + "type": "enum", + "symbols" : ["ONE", "TWO", "THREE"] + } + }, + { + "name": "anotherEnumField", + "type": "Kind", + "default": "TWO" + }, + { + "name": "enumFieldWithDiffDefault", + "default": "B", + "type": { + "name": "someKind", + "type": "enum", + "symbols": ["A", "B", "C"], + "default": "A" + } + }, + { + "name": "floatField", + "type": "float", + "default": 9.18 + } + ] +} \ No newline at end of file diff --git a/cross-region-replication-converter/src/test/avro/RepeatedTypeWithDocFull.avsc b/cross-region-replication-converter/src/test/avro/RepeatedTypeWithDocFull.avsc new file mode 100644 index 00000000..5cf02e9b --- /dev/null +++ b/cross-region-replication-converter/src/test/avro/RepeatedTypeWithDocFull.avsc @@ -0,0 +1,96 @@ +{ + "name": "RepeatedTypeWithDoc", + "namespace": "com.rr.avro.test", + "type": "record", + "doc": "record's doc", + "fields": [ + { + "name": "stringField", + "type": "string", + "doc": "field's doc" + }, + { + "name": "anotherStringField", + "type": "string" + }, + { + "name": "recordField", + "doc": "record field's doc", + "type": { + "name": "NestedRecord", + "type": "record", + "doc": "nested record's doc", + "fields": [ + { + "name": "nestedRecordField", + "doc": "nested record field's doc", + "type": { + "name": "FixedType", + "type": "fixed", + "size": 4 + } + }, + { + "name": "anotherNestedRecordField", + "type": "FixedType" + } + ] + } + }, + { + "name": "anotherRecordField", + "type": "NestedRecord", + "doc": "another record field's doc" + }, + { + "name": "recordFieldWithoutDoc", + "type": "NestedRecord" + }, + { + "name": "doclessRecordField", + "type": { + "name": "DoclessNestedRecord", + "type": "record", + "fields": [ + { + "name": "aField", + "type": "string" + } + ] + } + }, + { + "name": "doclessRecordFieldWithDoc", + "type": "DoclessNestedRecord", + "doc": "docless record field's doc" + }, + { + "name": "enumField", + "doc": "enum field's doc", + "type": { + "name": "Kind", + "type": "enum", + "doc": "enum's doc", + "symbols" : ["ONE", "TWO", "THREE"] + } + }, + { + "name": "anotherEnumField", + "type": "Kind", + "doc": "another enum field's doc" + }, + { + "name": "doclessEnumField", + "type": "Kind" + }, + { + "name": "diffEnumField", + "type": { + "name": "anotherKind", + "type": "enum", + "doc": "diffEnum's doc", + "symbols": ["A", "B", "C"] + } + } + ] +} \ No newline at end of file diff --git a/cross-region-replication-converter/src/test/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverterTest.java b/cross-region-replication-converter/src/test/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverterTest.java new file mode 100644 index 00000000..2fd278a8 --- /dev/null +++ b/cross-region-replication-converter/src/test/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverterTest.java @@ -0,0 +1,362 @@ +package com.amazonaws.services.crossregion.schemaregistry.kafkaconnect; + +import com.amazonaws.services.schemaregistry.common.Schema; +import com.amazonaws.services.schemaregistry.deserializers.GlueSchemaRegistryDeserializationFacade; +import com.amazonaws.services.schemaregistry.deserializers.GlueSchemaRegistryDeserializerImpl; +import com.amazonaws.services.schemaregistry.exception.AWSSchemaRegistryException; +import com.amazonaws.services.schemaregistry.serializers.GlueSchemaRegistrySerializationFacade; +import com.amazonaws.services.schemaregistry.serializers.GlueSchemaRegistrySerializerImpl; +import com.amazonaws.services.schemaregistry.utils.AWSSchemaRegistryConstants; +import com.amazonaws.services.schemaregistry.utils.AvroRecordType; + +import org.apache.kafka.connect.data.Struct; +import org.apache.kafka.connect.data.SchemaBuilder; +import org.apache.kafka.connect.errors.DataException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.services.glue.model.DataFormat; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.when; + +/** + * Unit tests for testing RegisterSchema class. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) + +public class CrossRegionReplicationConverterTest { + @Mock + private AwsCredentialsProvider credProvider; + @Mock + private GlueSchemaRegistryDeserializerImpl deserializer; + @Mock + private GlueSchemaRegistrySerializerImpl serializer; + private final static byte[] ENCODED_DATA = new byte[] { 8, 9, 12, 83, 82 }; + private final static byte[] USER_DATA = new byte[] { 12, 83, 82 }; + private static final String testTopic = "User-Topic"; + private CrossRegionReplicationConverter converter; + + byte[] genericBytes = new byte[] {3, 0, -73, -76, -89, -16, -100, -106, 78, 74, -90, -121, -5, + 93, -23, -17, 12, 99, 10, 115, 97, 110, 115, 97, -58, 1, 6, 114, 101, 100}; + byte[] avroBytes = new byte[] {3, 0, 84, 24, 47, -109, 37, 124, 74, 77, -100, + -98, -12, 118, 41, 32, 57, -66, 30, 101, 110, 116, 101, 114, 116, 97, 105, 110, 109, 101, 110, + 116, 95, 50, 0, 0, 0, 0, 0, 0, 20, 64}; + byte[] jsonBytes = new byte[] {3, 0, -73, -76, -89, -16, -100, -106, 78, 74, -90, -121, -5, 93, -23, -17, 12, 99, 123, 34, + 102, 105, 114, 115, 116, 78, 97, 109, 101, 34, 58, 34, 74, 111, 104, 110, 34, 44, 34, 108, 97, + 115, 116, 78, 97, 109, 101, 34, 58, 34, 68, 111, 101, 34, 44, 34, 97, 103, 101, 34, 58, 50, 49, + 125}; + byte[] protobufBytes = "foo".getBytes(StandardCharsets.UTF_8); + + @BeforeEach + void setUp() { + converter = new CrossRegionReplicationConverter(credProvider, deserializer, serializer); + } + + /** + * Test for Converter config method. + */ + @Test + public void testConverter_configure() { + converter = new CrossRegionReplicationConverter(); + converter.configure(getTestProperties(), false); + assertNotNull(converter); + assertNotNull(converter.getCredentialsProvider()); + assertNotNull(converter.getSerializer()); + assertNotNull(converter.getDeserializer()); + assertNotNull(converter.isKey()); + } + + /** + * Test for Converter when source region config is not provided. + */ + @Test + public void testConverter_sourceRegionNotProvided_throwsException(){ + converter = new CrossRegionReplicationConverter(); + Exception exception = assertThrows(DataException.class, () -> converter.configure(getNoSourceRegionProperties(), false)); + assertEquals("Source Region is not provided.", exception.getMessage()); + } + + /** + * Test for Converter when source region config is not provided. + */ + @Test + public void testConverter_targetRegionNotProvided_throwsException(){ + converter = new CrossRegionReplicationConverter(); + Exception exception = assertThrows(DataException.class, () -> converter.configure(getNoTargetRegionProperties(), false)); + assertEquals("Target Region is not provided.", exception.getMessage()); + } + + /** + * Test for Converter when source region config is not provided. + */ + @Test + public void testConverter_targetRegionReplacedByRegion_Succeeds(){ + converter = new CrossRegionReplicationConverter(); + converter.configure(getTargetRegionReplacedProperties(), false); + assertNotNull(converter.getSerializer()); + } + + /** + * Test Converter when it returns null given the input value is null. + */ + @Test + public void testConverter_fromConnectData_returnsByte0() { + Struct expected = createStructRecord(); + assertNull(converter.fromConnectData(testTopic, expected.schema(), null)); + } + + /** + * Test Converter when serializer throws exception with Avro schema. + */ + @Test + public void testConverter_fromConnectData_serializer_avroSchema_throwsException() { + Schema SCHEMA_REGISTRY_SCHEMA = new Schema("{}", DataFormat.AVRO.name(), "schemaFoo"); + Struct expected = createStructRecord(); + doReturn(USER_DATA) + .when(deserializer).getData(genericBytes); + doReturn(SCHEMA_REGISTRY_SCHEMA) + .when(deserializer).getSchema(genericBytes); + when(serializer.encode(testTopic, SCHEMA_REGISTRY_SCHEMA, USER_DATA)).thenThrow(new AWSSchemaRegistryException()); + assertThrows(DataException.class, () -> converter.fromConnectData(testTopic, expected.schema(), genericBytes)); + } + + /** + * Test Converter when the deserializer throws exception with Avro schema. + */ + @Test + public void testConverter_fromConnectData_deserializer_avroSchema_throwsException() { + Schema SCHEMA_REGISTRY_SCHEMA = new Schema("{}", DataFormat.AVRO.name(), "schemaFoo"); + Struct expected = createStructRecord(); + when((deserializer).getData(genericBytes)).thenThrow(new AWSSchemaRegistryException()); + doReturn(SCHEMA_REGISTRY_SCHEMA) + .when(deserializer).getSchema(genericBytes); + doReturn(ENCODED_DATA) + .when(serializer).encode(null, SCHEMA_REGISTRY_SCHEMA, USER_DATA); + assertThrows(DataException.class, () -> converter.fromConnectData(testTopic, expected.schema(), genericBytes)); + } + + /** + * Test Converter when Avro schema is replicated. + */ + @Test + public void testConverter_fromConnectData_avroSchema_succeeds() { + String schemaDefinition = """ + {"namespace": "com.amazonaws.services.schemaregistry.serializers.avro", + "type": "record", + "name": "payment", + "fields": [ + {"name": "id", "type": "string"}, + {"name": "id_6", "type": "double"} + ]}"""; + Schema testSchema = new Schema(schemaDefinition, DataFormat.AVRO.name(), testTopic); + Struct expected = createStructRecord(); + doReturn(genericBytes). + when(deserializer).getData(avroBytes); + doReturn(testSchema). + when(deserializer).getSchema(avroBytes); + doReturn(ENCODED_DATA) + .when(serializer).encode(testTopic, testSchema, genericBytes); + assertEquals(converter.fromConnectData(testTopic, expected.schema(), avroBytes), ENCODED_DATA); + } + + /** + * Test Converter when serializer throws exception with JSON schema. + */ + @Test + public void testConverter_fromConnectData_serializer_jsonSchema_throwsException() { + Schema SCHEMA_REGISTRY_SCHEMA = new Schema("{}", DataFormat.JSON.name(), "schemaFoo"); + Struct expected = createStructRecord(); + doReturn(USER_DATA) + .when(deserializer).getData(genericBytes); + doReturn(SCHEMA_REGISTRY_SCHEMA) + .when(deserializer).getSchema(genericBytes); + when(serializer.encode(testTopic, SCHEMA_REGISTRY_SCHEMA, USER_DATA)).thenThrow(new AWSSchemaRegistryException()); + assertThrows(DataException.class, () -> converter.fromConnectData(testTopic, expected.schema(), genericBytes)); + } + + /** + * Test Converter when the deserializer throws exception with JSON schema. + */ + @Test + public void testConverter_fromConnectData_deserializer_jsonSchema_throwsException() { + Schema SCHEMA_REGISTRY_SCHEMA = new Schema("{}", DataFormat.JSON.name(), "schemaFoo"); + Struct expected = createStructRecord(); + when((deserializer).getData(genericBytes)).thenThrow(new AWSSchemaRegistryException()); + doReturn(SCHEMA_REGISTRY_SCHEMA) + .when(deserializer).getSchema(genericBytes); + doReturn(ENCODED_DATA) + .when(serializer).encode("schemaFoo", SCHEMA_REGISTRY_SCHEMA, USER_DATA); + assertThrows(DataException.class, () -> converter.fromConnectData(testTopic, expected.schema(), genericBytes)); + } + + /** + * Test Converter when JSON schema is replicated. + */ + @Test + public void testConverter_fromConnectData_jsonSchema_succeeds() { + String testSchemaDefinition = "{\"$id\":\"https://example.com/geographical-location.schema.json\"," + + "\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"title\":\"Longitude " + + "and Latitude Values\",\"description\":\"A geographical coordinate.\"," + + "\"required\":[\"latitude\",\"longitude\"],\"type\":\"object\"," + + "\"properties\":{\"latitude\":{\"type\":\"number\",\"minimum\":-90," + + "\"maximum\":90},\"longitude\":{\"type\":\"number\",\"minimum\":-180," + + "\"maximum\":180}},\"additionalProperties\":false}"; + Schema testSchema = new Schema(testSchemaDefinition, DataFormat.JSON.name(), testTopic); + Struct expected = createStructRecord(); + doReturn(genericBytes). + when(deserializer).getData(jsonBytes); + doReturn(testSchema). + when(deserializer).getSchema(jsonBytes); + doReturn(ENCODED_DATA) + .when(serializer).encode(testTopic, testSchema, genericBytes); + assertEquals(converter.fromConnectData(testTopic, expected.schema(), jsonBytes), ENCODED_DATA); + } + + /** + * Test Converter when serializer throws exception with protobuf schema. + */ + @Test + public void testConverter_fromConnectData_serializer_protobufSchema_throwsException() { + Schema SCHEMA_REGISTRY_SCHEMA = new Schema("{}", DataFormat.PROTOBUF.name(), "schemaFoo"); + Struct expected = createStructRecord(); + doReturn(USER_DATA) + .when(deserializer).getData(genericBytes); + doReturn(SCHEMA_REGISTRY_SCHEMA) + .when(deserializer).getSchema(genericBytes); + when(serializer.encode(testTopic, SCHEMA_REGISTRY_SCHEMA, USER_DATA)).thenThrow(new AWSSchemaRegistryException()); + assertThrows(DataException.class, () -> converter.fromConnectData(testTopic, expected.schema(), genericBytes)); + } + + /** + * Test Converter when the deserializer throws exception with protobuf schema. + */ + @Test + public void testConverter_fromConnectData_deserializer_protobufSchema_throwsException() { + Schema SCHEMA_REGISTRY_SCHEMA = new Schema("{}", DataFormat.PROTOBUF.name(), "schemaFoo"); + Struct expected = createStructRecord(); + when((deserializer).getData(genericBytes)).thenThrow(new AWSSchemaRegistryException()); + doReturn(SCHEMA_REGISTRY_SCHEMA) + .when(deserializer).getSchema(genericBytes); + doReturn(ENCODED_DATA) + .when(serializer).encode("schemaFoo", SCHEMA_REGISTRY_SCHEMA, USER_DATA); + assertThrows(DataException.class, () -> converter.fromConnectData(testTopic, expected.schema(), genericBytes)); + } + + /** + * Test Converter when Protobuf schema is replicated. + */ + @Test + public void getSchema_protobuf_succeeds(){ + Schema testSchema = new Schema("foo", DataFormat.PROTOBUF.name(), testTopic); + Struct expected = createStructRecord(); + doReturn(genericBytes). + when(deserializer).getData(protobufBytes); + doReturn(testSchema). + when(deserializer).getSchema(protobufBytes); + doReturn(ENCODED_DATA) + .when(serializer).encode(testTopic, testSchema, genericBytes); + assertEquals(converter.fromConnectData(testTopic, expected.schema(), protobufBytes), ENCODED_DATA); + } + + /** + * Test toConnectData when IllegalAccessException is thrown. + */ + @Test + public void toConnectData_throwsException(){ + assertThrows(UnsupportedOperationException.class, () -> converter.toConnectData(testTopic, genericBytes)); + } + + /** + * To create a map of configurations without source region. + * + * @return a map of configurations + */ + private Map getNoSourceRegionProperties() { + Map props = new HashMap<>(); + + props.put(AWSSchemaRegistryConstants.AWS_REGION, "us-east-1"); + props.put(AWSSchemaRegistryConstants.AWS_TARGET_REGION, "us-east-1"); + props.put(AWSSchemaRegistryConstants.AWS_ENDPOINT, "https://test"); + props.put(AWSSchemaRegistryConstants.SCHEMA_AUTO_REGISTRATION_SETTING, true); + props.put(AWSSchemaRegistryConstants.AVRO_RECORD_TYPE, AvroRecordType.GENERIC_RECORD.getName()); + + return props; + } + + /** + * To create a map of configurations without target region. + * + * @return a map of configurations + */ + private Map getNoTargetRegionProperties() { + Map props = new HashMap<>(); + + props.put(AWSSchemaRegistryConstants.AWS_SOURCE_REGION, "us-west-2"); + props.put(AWSSchemaRegistryConstants.AWS_ENDPOINT, "https://test"); + props.put(AWSSchemaRegistryConstants.SCHEMA_AUTO_REGISTRATION_SETTING, true); + props.put(AWSSchemaRegistryConstants.AVRO_RECORD_TYPE, AvroRecordType.GENERIC_RECORD.getName()); + + return props; + } + + /** + * To create a map of configurations without target region, but is replaced by the provided region config. + * + * @return a map of configurations + */ + private Map getTargetRegionReplacedProperties() { + Map props = new HashMap<>(); + + props.put(AWSSchemaRegistryConstants.AWS_REGION, "us-east-1"); + props.put(AWSSchemaRegistryConstants.AWS_SOURCE_REGION, "us-west-2"); + props.put(AWSSchemaRegistryConstants.AWS_ENDPOINT, "https://test"); + props.put(AWSSchemaRegistryConstants.SCHEMA_AUTO_REGISTRATION_SETTING, true); + props.put(AWSSchemaRegistryConstants.AVRO_RECORD_TYPE, AvroRecordType.GENERIC_RECORD.getName()); + + return props; + } + + /** + * To create a map of configurations. + * + * @return a map of configurations + */ + private Map getTestProperties() { + Map props = new HashMap<>(); + + props.put(AWSSchemaRegistryConstants.AWS_SOURCE_REGION, "us-west-2"); + props.put(AWSSchemaRegistryConstants.AWS_TARGET_REGION, "us-east-1"); + props.put(AWSSchemaRegistryConstants.REGISTRY_NAME, "default-registry"); + props.put(AWSSchemaRegistryConstants.SCHEMA_NAME, "test_schema"); + props.put(AWSSchemaRegistryConstants.AWS_ENDPOINT, "https://test"); + props.put(AWSSchemaRegistryConstants.AWS_SOURCE_ENDPOINT, "https://test"); + props.put(AWSSchemaRegistryConstants.SCHEMA_AUTO_REGISTRATION_SETTING, true); + props.put(AWSSchemaRegistryConstants.AVRO_RECORD_TYPE, AvroRecordType.GENERIC_RECORD.getName()); + + return props; + } + + /** + * To create a Connect Struct record. + * + * @return a Connect Struct + */ + private Struct createStructRecord() { + org.apache.kafka.connect.data.Schema schema = SchemaBuilder.struct() + .build(); + return new Struct(schema); + } +} diff --git a/cross-region-replication-mm2-converter/pom.xml b/cross-region-replication-mm2-converter/pom.xml deleted file mode 100644 index 583e526d..00000000 --- a/cross-region-replication-mm2-converter/pom.xml +++ /dev/null @@ -1,164 +0,0 @@ - - - 4.0.0 - - org.example - CrossRegionReplicationMm2Converter - 1.0-SNAPSHOT - - - 20 - 20 - UTF-8 - - - - - org.apache.kafka - kafka-clients - 3.2.3 - - - - org.apache.kafka - kafka-streams - 3.2.3 - - - - org.apache.kafka - connect-api - 3.2.3 - - - - software.amazon.glue - schema-registry-serde - 1.1.15 - - - - software.amazon.glue - schema-registry-common - 1.1.15 - - - - org.apache.avro - avro - 1.7.7 - - - - - - - - - - - - - - - - com.amazonaws - aws-java-sdk-sts - 1.12.151 - - - software.amazon.awssdk - sts - 2.17.122 - - - software.amazon.glue - schema-registry-common - 1.1.15 - - - software.amazon.glue - schema-registry-kafkastreams-serde - 1.1.15 - - - software.amazon.glue - schema-registry-kafkaconnect-converter - 1.1.5 - - - software.amazon.awssdk - arns - 2.17.122 - - - - com.amazonaws - aws-java-sdk-glue - 1.12.497 - - - - com.google.protobuf - protobuf-java - 3.19.6 - test - - - - org.projectlombok - lombok - 1.18.24 - provided - - - - org.mockito - mockito-core - 5.3.1 - test - - - org.mockito - mockito-junit-jupiter - 5.3.1 - test - - - - junit - junit - 4.12 - test - - - org.junit.jupiter - junit-jupiter-engine - 5.9.1 - test - - - org.junit.jupiter - junit-jupiter-params - 5.9.1 - test - - - org.junit.platform - junit-platform-commons - 1.9.2 - test - - - org.junit.jupiter - junit-jupiter-api - 5.9.1 - test - - - - - - diff --git a/cross-region-replication-mm2-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2Converter.java b/cross-region-replication-mm2-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2Converter.java deleted file mode 100644 index 8ac614fe..00000000 --- a/cross-region-replication-mm2-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2Converter.java +++ /dev/null @@ -1,161 +0,0 @@ -package com.amazonaws.services.crossregion.schemaregistry.kafkaconnect; - -import com.amazonaws.services.schemaregistry.common.AWSSerializerInput; -import com.amazonaws.services.schemaregistry.common.Schema; -import com.amazonaws.services.schemaregistry.common.configs.GlueSchemaRegistryConfiguration; -import com.amazonaws.services.schemaregistry.deserializers.GlueSchemaRegistryDeserializationFacade; -import com.amazonaws.services.schemaregistry.deserializers.GlueSchemaRegistryDeserializerImpl; -import com.amazonaws.services.schemaregistry.exception.AWSSchemaRegistryException; -import com.amazonaws.services.schemaregistry.serializers.GlueSchemaRegistrySerializationFacade; -import com.amazonaws.services.schemaregistry.serializers.GlueSchemaRegistrySerializerImpl; -import com.amazonaws.services.schemaregistry.utils.AWSSchemaRegistryConstants; - -import kotlinx.serialization.SerializationException; -import lombok.Getter; - -import org.apache.kafka.connect.data.*; -import org.apache.kafka.connect.errors.DataException; -import org.apache.kafka.connect.storage.Converter; -import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; -import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; - -import java.util.HashMap; -import java.util.Map; -import java.util.UUID; - -public class CrossRegionReplicationMM2Converter implements Converter { - @Getter - private GlueSchemaRegistryDeserializationFacade deserializationFacade; - @Getter - private GlueSchemaRegistrySerializationFacade serializationFacade; - @Getter - private AwsCredentialsProvider credentialsProvider; - @Getter - private GlueSchemaRegistryDeserializerImpl deserializer; - @Getter - private GlueSchemaRegistrySerializerImpl serializer; - @Getter - private boolean isKey; - - /** - * Constructor used by Kafka Connect user. - */ - public CrossRegionReplicationMM2Converter(){}; - - /** - * Constructor accepting AWSCredentialsProvider. - * - * @param credentialsProvider AWSCredentialsProvider instance. - */ - public CrossRegionReplicationMM2Converter( - GlueSchemaRegistryDeserializationFacade deserializationFacade, - GlueSchemaRegistrySerializationFacade serializationFacade, - AwsCredentialsProvider credentialsProvider, - GlueSchemaRegistryDeserializerImpl deserializerImpl, - GlueSchemaRegistrySerializerImpl serializerImpl) { - this.deserializationFacade = deserializationFacade; - this.serializationFacade = serializationFacade; - this.credentialsProvider = credentialsProvider; - this.deserializer = deserializerImpl; - this.serializer = serializerImpl; - } - - /** - * Configure the MM2 Schema Replication Converter. - * @param configs configuration elements for the converter - * @param isKey true if key, false otherwise - */ - @Override - public void configure(Map configs, boolean isKey) { - this.isKey = isKey; - new CrossRegionReplicationMM2ConverterConfig(configs); - - - credentialsProvider = DefaultCredentialsProvider.builder().build(); - - // Put the source and target regions into configurations respectively - Map sourceConfigs = new HashMap<>(configs); - Map targetConfigs = new HashMap<>(configs); - - sourceConfigs.put(AWSSchemaRegistryConstants.AWS_REGION, configs.get(AWSSchemaRegistryConstants.AWS_SRC_REGION)); - targetConfigs.put(AWSSchemaRegistryConstants.AWS_REGION, configs.get(AWSSchemaRegistryConstants.AWS_TGT_REGION)); - - deserializationFacade = - GlueSchemaRegistryDeserializationFacade.builder() - .credentialProvider(credentialsProvider) - .configs(sourceConfigs) - .build(); - - serializationFacade = - GlueSchemaRegistrySerializationFacade.builder() - .credentialProvider(credentialsProvider) - .configs(targetConfigs) - .build(); - - serializer = new GlueSchemaRegistrySerializerImpl(credentialsProvider, new GlueSchemaRegistryConfiguration(targetConfigs)); - - deserializer = new GlueSchemaRegistryDeserializerImpl(credentialsProvider, new GlueSchemaRegistryConfiguration(sourceConfigs)); - - -// deserializationFacade = new GlueSchemaRegistryDeserializationFacade(new GlueSchemaRegistryConfiguration(sourceConfigs), credentialsProvider); -// -// serializationFacade = new GlueSchemaRegistrySerializationFacade(credentialsProvider, null, new GlueSchemaRegistryConfiguration(targetConfigs), null, null); - - - } - - @Override - public byte[] fromConnectData(String topic, org.apache.kafka.connect.data.Schema schema, Object value) { - byte[] bytes = (byte[]) value; - if (value == null) return new byte[0]; - - try { - byte[] deserializedBytes = deserializer.getData(bytes); - Schema deserializedSchema = deserializer.getSchema(bytes); - - byte[] encodedByte = serializer.encode(null, deserializedSchema, deserializedBytes); - com.amazonaws.services.schemaregistry.common.Schema returnedSchema = getSchema(bytes); - UUID uuid = registerSchema(returnedSchema); - - return encodedByte; - } catch (SerializationException | AWSSchemaRegistryException e){ - throw new DataException("Converting Kafka Connect data to byte[] failed due to serialization/deserialization error: ", e); - } - - } - - @Override - public SchemaAndValue toConnectData(String s, byte[] bytes) { - return null; - } - - /** - * Retrieve schema from source region GSR using schema header of the serialized messages - * @param data serialized message obtained from MM2 - * @return schema - */ - public Schema getSchema(byte[] data){ - if (data == null) { - throw new NullPointerException("Empty Data"); - } - return deserializationFacade.getSchema(data); - - } - - /** - * Register schema in the target region GSR - * @param schema schema obtained from the source region GSR - * @return schema version ID of the registered schema - */ - public UUID registerSchema(com.amazonaws.services.schemaregistry.common.Schema schema){ - try{ - String schemaDefinition = schema.getSchemaDefinition(); - String schemaName = schema.getSchemaName(); - String dataFormat = schema.getDataFormat(); - AWSSerializerInput input = new AWSSerializerInput(schemaDefinition, schemaName, dataFormat, null); - return serializationFacade.getOrRegisterSchemaVersion(input); - } catch (Exception e){ - throw new DataException("Schema can't be register"); - } - } -} diff --git a/cross-region-replication-mm2-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2ConverterConfig.java b/cross-region-replication-mm2-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2ConverterConfig.java deleted file mode 100644 index 1a66c4e6..00000000 --- a/cross-region-replication-mm2-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2ConverterConfig.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.amazonaws.services.crossregion.schemaregistry.kafkaconnect; - -import org.apache.kafka.common.config.AbstractConfig; -import org.apache.kafka.common.config.ConfigDef; - -import java.util.Map; - -public class CrossRegionReplicationMM2ConverterConfig extends AbstractConfig { - - public static ConfigDef configDef() { - return new ConfigDef(); - } - /** - * Constructor used by CrossRegionReplicationMM2Converter. - * @param props property elements for the converter config - */ - public CrossRegionReplicationMM2ConverterConfig(Map props) { - super(configDef(), props); - } -} diff --git a/cross-region-replication-mm2-converter/src/main/resources/META-INF/maven/archetype.xml b/cross-region-replication-mm2-converter/src/main/resources/META-INF/maven/archetype.xml deleted file mode 100644 index 104a3b2c..00000000 --- a/cross-region-replication-mm2-converter/src/main/resources/META-INF/maven/archetype.xml +++ /dev/null @@ -1,9 +0,0 @@ - - cross-region-replication-mm2-converter - - src/main/java/App.java - - - src/test/java/AppTest.java - - diff --git a/cross-region-replication-mm2-converter/src/main/resources/archetype-resources/pom.xml b/cross-region-replication-mm2-converter/src/main/resources/archetype-resources/pom.xml deleted file mode 100644 index 96f1bced..00000000 --- a/cross-region-replication-mm2-converter/src/main/resources/archetype-resources/pom.xml +++ /dev/null @@ -1,15 +0,0 @@ - - 4.0.0 - $org.example - $cross-region-replication-mm2-converter - $1.1.15 - - - junit - junit - 3.8.1 - test - - - diff --git a/cross-region-replication-mm2-converter/src/main/resources/archetype-resources/src/main/java/App.java b/cross-region-replication-mm2-converter/src/main/resources/archetype-resources/src/main/java/App.java deleted file mode 100644 index 1fa6a956..00000000 --- a/cross-region-replication-mm2-converter/src/main/resources/archetype-resources/src/main/java/App.java +++ /dev/null @@ -1,13 +0,0 @@ -package $org.example; - -/** - * Hello world! - * - */ -public class App -{ - public static void main( String[] args ) - { - System.out.println( "Hello World!" ); - } -} diff --git a/cross-region-replication-mm2-converter/src/main/resources/archetype-resources/src/test/java/AppTest.java b/cross-region-replication-mm2-converter/src/main/resources/archetype-resources/src/test/java/AppTest.java deleted file mode 100644 index 65be417e..00000000 --- a/cross-region-replication-mm2-converter/src/main/resources/archetype-resources/src/test/java/AppTest.java +++ /dev/null @@ -1,38 +0,0 @@ -package $org.example; - -import junit.framework.Test; -import junit.framework.TestCase; -import junit.framework.TestSuite; - -/** - * Unit test for simple App. - */ -public class AppTest - extends TestCase -{ - /** - * Create the test case - * - * @param testName name of the test case - */ - public AppTest( String testName ) - { - super( testName ); - } - - /** - * @return the suite of tests being tested - */ - public static Test suite() - { - return new TestSuite( AppTest.class ); - } - - /** - * Rigourous Test :-) - */ - public void testApp() - { - assertTrue( true ); - } -} diff --git a/cross-region-replication-mm2-converter/src/test/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2ConverterTest.java b/cross-region-replication-mm2-converter/src/test/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2ConverterTest.java deleted file mode 100644 index a0175512..00000000 --- a/cross-region-replication-mm2-converter/src/test/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationMM2ConverterTest.java +++ /dev/null @@ -1,344 +0,0 @@ -package com.amazonaws.services.crossregion.schemaregistry.kafkaconnect; - -import com.amazonaws.services.schemaregistry.common.AWSSerializerInput; -import com.amazonaws.services.schemaregistry.common.Schema; -import com.amazonaws.services.schemaregistry.deserializers.GlueSchemaRegistryDeserializationFacade; -import com.amazonaws.services.schemaregistry.deserializers.GlueSchemaRegistryDeserializerImpl; -import com.amazonaws.services.schemaregistry.exception.AWSSchemaRegistryException; -import com.amazonaws.services.schemaregistry.serializers.GlueSchemaRegistrySerializationFacade; -import com.amazonaws.services.schemaregistry.serializers.GlueSchemaRegistrySerializerImpl; -import com.amazonaws.services.schemaregistry.utils.AWSSchemaRegistryConstants; -import com.amazonaws.services.schemaregistry.utils.AvroRecordType; - -import org.apache.kafka.connect.data.Struct; -import org.apache.kafka.connect.data.SchemaBuilder; -import org.apache.kafka.connect.errors.DataException; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.mockito.junit.jupiter.MockitoSettings; -import org.mockito.quality.Strictness; -import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; -import software.amazon.awssdk.services.glue.model.DataFormat; - -import java.nio.charset.StandardCharsets; -import java.util.Arrays; -import java.util.HashMap; -import java.util.Map; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.when; - -/** - * Unit tests for testing RegisterSchema class. - */ -@ExtendWith(MockitoExtension.class) -@MockitoSettings(strictness = Strictness.LENIENT) - -public class CrossRegionReplicationMM2ConverterTest { - @Mock - private GlueSchemaRegistryDeserializationFacade deserializationFacade; - @Mock - private GlueSchemaRegistrySerializationFacade serializationFacade; - @Mock - private AwsCredentialsProvider credProvider; - @Mock - private GlueSchemaRegistryDeserializerImpl deserializer; - @Mock - private GlueSchemaRegistrySerializerImpl serializer; - private final static byte[] ENCODED_DATA = new byte[] { 8, 9, 12, 83, 82 }; - private final static byte[] USER_DATA = new byte[] { 12, 83, 82 }; - private final static Schema SCHEMA_REGISTRY_SCHEMA = new Schema("{}", "AVRO", "schemaFoo"); - private static final String testTopic = "User-Topic"; - - byte[] genericBytes = new byte[] {3, 0, -73, -76, -89, -16, -100, -106, 78, 74, -90, -121, -5, - 93, -23, -17, 12, 99, 10, 115, 97, 110, 115, 97, -58, 1, 6, 114, 101, 100}; - - - byte[] avroBytes = new byte[] {3, 0, 84, 24, 47, -109, 37, 124, 74, 77, -100, - -98, -12, 118, 41, 32, 57, -66, 30, 101, 110, 116, 101, 114, 116, 97, 105, 110, 109, 101, 110, - 116, 95, 50, 0, 0, 0, 0, 0, 0, 20, 64}; - - private CrossRegionReplicationMM2Converter converter; - - - - @BeforeEach - void setUp() { - converter = new CrossRegionReplicationMM2Converter(deserializationFacade, serializationFacade, credProvider, deserializer, serializer); - } - - /** - * Test for Converter config method. - */ - @Test - public void testConverter_configure() { - converter = new CrossRegionReplicationMM2Converter(); - converter.configure(getTestProperties(), false); - assertNotNull(converter); - assertNotNull(converter.getDeserializationFacade()); - assertNotNull(converter.getSerializationFacade()); - assertNotNull(converter.getCredentialsProvider()); - assertNotNull(converter.getSerializer()); - assertNotNull(converter.getDeserializer()); - assertNotNull(converter.isKey()); - - } - - /** - * Test Mm2Converter when it returns byte 0 given the input value is null. - */ - @Test - public void testConverter_fromConnectData_returnsByte0() { - Struct expected = createStructRecord(); - assertEquals(Arrays.toString(converter.fromConnectData(testTopic, expected.schema(), null)), Arrays.toString(new byte[0])); - } - - /** - * Test Mm2Converter when serializer throws exception. - */ - @Test - public void testConverter_fromConnectData_throwsException() { - Struct expected = createStructRecord(); - doReturn(USER_DATA) - .when(deserializer).getData(genericBytes); - doReturn(SCHEMA_REGISTRY_SCHEMA) - .when(deserializer).getSchema(genericBytes); - when(serializer.encode(null, SCHEMA_REGISTRY_SCHEMA, USER_DATA)).thenThrow(new AWSSchemaRegistryException()); - assertThrows(DataException.class, () -> converter.fromConnectData(testTopic, expected.schema(), genericBytes)); - } - - /** - * Test Mm2Converter when the deserializer throws exception. - */ - @Test - public void testConverter_fromConnectData_deserializer_getData_ThrowsException() { - Struct expected = createStructRecord(); - when((deserializer).getData(genericBytes)).thenThrow(new AWSSchemaRegistryException()); - doReturn(SCHEMA_REGISTRY_SCHEMA) - .when(deserializer).getSchema(genericBytes); - doReturn(ENCODED_DATA) - .when(serializer).encode(null, SCHEMA_REGISTRY_SCHEMA, USER_DATA); - assertThrows(DataException.class, () -> converter.fromConnectData(testTopic, expected.schema(), genericBytes)); - } - - /** - * Test getSchema when NullPointerException is thrown. - */ - @Test - public void testGetSchema_nullObject_throwsException(){ - assertThrows(NullPointerException.class, () -> converter.getSchema(null)); - } - - - /** - * Test getSchema when an existing Avro schema is being successfully retrieved. - */ - @Test - public void getSchema_avro_succeeds(){ - - String schemaDefinition = """ - {"namespace": "com.amazonaws.services.schemaregistry.serializers.avro", - "type": "record", - "name": "payment", - "fields": [ - {"name": "id", "type": "string"}, - {"name": "id_6", "type": "double"} - ]}"""; - Schema testSchema = new Schema(schemaDefinition, DataFormat.AVRO.name(), testTopic); - - doReturn(testSchema). - when(deserializationFacade).getSchema(avroBytes); - Schema returnedSchema = converter.getSchema(avroBytes); - - assertEquals(returnedSchema.getSchemaDefinition(), testSchema.getSchemaDefinition()); - assertEquals(returnedSchema.getSchemaName(), testSchema.getSchemaName()); - assertEquals(returnedSchema.getDataFormat(), testSchema.getDataFormat()); - } - - /** - * Test getSchema when an existing JSON schema is being successfully retrieved. - */ - @Test - public void getSchema_json_succeeds() { - - String testSchemaDefinition = "{\"$id\":\"https://example.com/geographical-location.schema.json\"," - + "\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"title\":\"Longitude " - + "and Latitude Values\",\"description\":\"A geographical coordinate.\"," - + "\"required\":[\"latitude\",\"longitude\"],\"type\":\"object\"," - + "\"properties\":{\"latitude\":{\"type\":\"number\",\"minimum\":-90," - + "\"maximum\":90},\"longitude\":{\"type\":\"number\",\"minimum\":-180," - + "\"maximum\":180}},\"additionalProperties\":false}"; - - String jsonData = "{\"latitude\":48.858093,\"longitude\":2.294694}"; - byte[] jsonBytes = jsonData.getBytes(StandardCharsets.UTF_8); - Schema testSchema = new Schema(testSchemaDefinition, DataFormat.JSON.name(), testTopic); - - doReturn(testSchema). - when(deserializationFacade).getSchema(jsonBytes); - Schema returnedSchema = converter.getSchema(jsonBytes); - - assertEquals(returnedSchema.getSchemaDefinition(), testSchema.getSchemaDefinition()); - assertEquals(returnedSchema.getSchemaName(), testSchema.getSchemaName()); - assertEquals(returnedSchema.getDataFormat(), testSchema.getDataFormat()); - } - - /** - * Test getSchema when an existing Protobuf schema is being successfully retrieved. - */ - @Test - public void getSchema_protobuf_succeeds(){ - - Schema testSchema = new Schema("foo", DataFormat.PROTOBUF.name(), testTopic); - byte[] protobufBytes = "foo".getBytes(StandardCharsets.UTF_8); - - doReturn(testSchema). - when(deserializationFacade).getSchema(protobufBytes); - Schema returnedSchema = converter.getSchema(protobufBytes); - - assertEquals(returnedSchema.getSchemaDefinition(), testSchema.getSchemaDefinition()); - assertEquals(returnedSchema.getSchemaName(), testSchema.getSchemaName()); - assertEquals(returnedSchema.getDataFormat(), testSchema.getDataFormat()); - } - - /** - * Test registerSchema when NullPointerException is thrown. - */ - @Test - public void registerSchema_throwsDataException() { - assertThrows(DataException.class, () -> converter.registerSchema(null)); - } - - /** - * Test registerSchema for existing Avro schema. - */ - @Test - public void registerSchema_avro_nonExisting_succeeds() { - - String schemaDefinition = """ - {"namespace": "com.amazonaws.services.schemaregistry.serializers.avro", - "type": "record", - "name": "payment", - "fields": [ - {"name": "id", "type": "string"}, - {"name": "id_6", "type": "double"} - ]}"""; - UUID avroBytesVersionID = UUID.fromString("54182f93-257c-4a4d-9c9e-f476292039be"); - - Schema testSchema = new Schema(schemaDefinition, DataFormat.AVRO.name(), testTopic); - AWSSerializerInput input = new AWSSerializerInput(testSchema.getSchemaDefinition(), testSchema.getSchemaName(), testSchema.getDataFormat(), null); - - doReturn(avroBytesVersionID). - when(serializationFacade).getOrRegisterSchemaVersion(input); - - assertEquals(converter.registerSchema(testSchema), avroBytesVersionID); - } - - /** - * Test registerSchema for existing JSON schema. - */ - @Test - public void registerSchema_json_nonExisting_succeeds() { - String testSchemaDefinition = "{\"$id\":\"https://example.com/geographical-location.schema.json\"," - + "\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"title\":\"Longitude " - + "and Latitude Values\",\"description\":\"A geographical coordinate.\"," - + "\"required\":[\"latitude\",\"longitude\"],\"type\":\"object\"," - + "\"properties\":{\"latitude\":{\"type\":\"number\",\"minimum\":-90," - + "\"maximum\":90},\"longitude\":{\"type\":\"number\",\"minimum\":-180," - + "\"maximum\":180}},\"additionalProperties\":false}"; - UUID jsonBytesVersionID = UUID.fromString("afba13fd-7c25-4202-8904-1fab3089faf9"); - - Schema testSchema = new Schema(testSchemaDefinition, DataFormat.JSON.name(), "testJson"); - AWSSerializerInput input = new AWSSerializerInput(testSchema.getSchemaDefinition(), testSchema.getSchemaName(), testSchema.getDataFormat(), null); - - doReturn(jsonBytesVersionID). - when(serializationFacade).getOrRegisterSchemaVersion(input); - - assertEquals(converter.registerSchema(testSchema), jsonBytesVersionID); - } - - /** - * Test registerSchema for non-existing Protobuf schema. - */ - @Test - public void registerSchema_protobuf_nonExisting_succeeds() { - String testSchemaDefinition = "foo"; - UUID protobufBytesVersionID = UUID.fromString("b7b4a7f0-9c96-4e4a-a687-fb5de9ef0c63"); - - Schema testSchema = new Schema(testSchemaDefinition, DataFormat.PROTOBUF.name(), testTopic); - AWSSerializerInput input = new AWSSerializerInput(testSchema.getSchemaDefinition(), testSchema.getSchemaName(), testSchema.getDataFormat(), null); - - doReturn(protobufBytesVersionID). - when(serializationFacade).getOrRegisterSchemaVersion(input); - - assertEquals(converter.registerSchema(testSchema), protobufBytesVersionID); - } - - - /** - * To create a map of configurations w/o source region. - * - * @return a map of configurations - */ - private Map getNoSourceProperties() { - Map props = new HashMap<>(); - - props.put("TARGET_REGION", "us-west-2"); - props.put(AWSSchemaRegistryConstants.AWS_ENDPOINT, "https://test"); - props.put(AWSSchemaRegistryConstants.SCHEMA_AUTO_REGISTRATION_SETTING, true); - props.put(AWSSchemaRegistryConstants.AVRO_RECORD_TYPE, AvroRecordType.GENERIC_RECORD.getName()); - - return props; - } - - /** - * To create a map of configurations w/o source region. - * - * @return a map of configurations - */ - private Map getNoTargetProperties() { - Map props = new HashMap<>(); - - props.put("SOURCE_REGION", "us-west-2"); - props.put(AWSSchemaRegistryConstants.AWS_ENDPOINT, "https://test"); - props.put(AWSSchemaRegistryConstants.SCHEMA_AUTO_REGISTRATION_SETTING, true); - props.put(AWSSchemaRegistryConstants.AVRO_RECORD_TYPE, AvroRecordType.GENERIC_RECORD.getName()); - - return props; - } - - /** - * To create a map of configurations. - * - * @return a map of configurations - */ - private Map getTestProperties() { - Map props = new HashMap<>(); - - props.put("SOURCE_REGION", "us-west-2"); - props.put("TARGET_REGION", "us-east-1"); - props.put(AWSSchemaRegistryConstants.REGISTRY_NAME, "default-registry"); - props.put(AWSSchemaRegistryConstants.SCHEMA_NAME, "t2"); - props.put(AWSSchemaRegistryConstants.AWS_ENDPOINT, "https://test"); - props.put(AWSSchemaRegistryConstants.AWS_SRC_ENDPOINT, "https://test"); - props.put(AWSSchemaRegistryConstants.SCHEMA_AUTO_REGISTRATION_SETTING, true); - props.put(AWSSchemaRegistryConstants.AVRO_RECORD_TYPE, AvroRecordType.GENERIC_RECORD.getName()); - - return props; - } - - /** - * To create a Connect Struct record. - * - * @return a Connect Struct - */ - private Struct createStructRecord() { - org.apache.kafka.connect.data.Schema schema = SchemaBuilder.struct() - .build(); - return new Struct(schema); - } -} diff --git a/pom.xml b/pom.xml index eef56a38..8f438972 100644 --- a/pom.xml +++ b/pom.xml @@ -74,7 +74,7 @@ integration-tests jsonschema-kafkaconnect-converter protobuf-kafkaconnect-converter - cross-region-replication-mm2-converter + cross-region-replication-converter From 2f30defda8de3355dad17250648ff8736a03c5a3 Mon Sep 17 00:00:00 2001 From: Jessie Chen Date: Wed, 26 Jul 2023 13:55:03 -0700 Subject: [PATCH 07/41] Modified Converter Based on First CR Feedback --- .../common/configs/GlueSchemaRegistryConfiguration.java | 3 ++- .../kafkaconnect/CrossRegionReplicationConverter.java | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/common/src/main/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfiguration.java b/common/src/main/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfiguration.java index b1c295ba..98b32cbc 100644 --- a/common/src/main/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfiguration.java +++ b/common/src/main/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfiguration.java @@ -46,7 +46,8 @@ public class GlueSchemaRegistryConfiguration { private AWSSchemaRegistryConstants.COMPRESSION compressionType = AWSSchemaRegistryConstants.COMPRESSION.NONE; private String endPoint; private String region; - //TODO: Remove configs that are not useful non replication use-cases. + // TODO: Remove configs that are not useful non replication use-cases. + // https://github.com/awslabs/aws-glue-schema-registry/issues/292 private String sourceEndPoint; private String sourceRegion; private String targetEndPoint; diff --git a/cross-region-replication-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverter.java b/cross-region-replication-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverter.java index a15ad0ce..d97943cc 100644 --- a/cross-region-replication-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverter.java +++ b/cross-region-replication-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverter.java @@ -55,7 +55,8 @@ public CrossRegionReplicationConverter( @Override public void configure(Map configs, boolean isKey) { this.isKey = isKey; - //TODO: Support credentialProvider passed on by the user + // TODO: Support credentialProvider passed on by the user + // https://github.com/awslabs/aws-glue-schema-registry/issues/293 credentialsProvider = DefaultCredentialsProvider.builder().build(); // Put the source and target regions into configurations respectively @@ -85,6 +86,8 @@ public byte[] fromConnectData(String topic, org.apache.kafka.connect.data.Schema byte[] deserializedBytes = deserializer.getData(bytes); Schema deserializedSchema = deserializer.getSchema(bytes); //The registry is decided by the configuration in the target region , schema name is the same as the source region + // TODO: Prefix topic name with source cluster alias + // https://github.com/awslabs/aws-glue-schema-registry/issues/294 return serializer.encode(topic, deserializedSchema, deserializedBytes); } catch (SerializationException | AWSSchemaRegistryException e){ From 03f9328487d5854554d348b59dd6eb20efec4765 Mon Sep 17 00:00:00 2001 From: Jessie Chen Date: Fri, 18 Aug 2023 10:40:36 -0700 Subject: [PATCH 08/41] Added TODOs --- .../common/configs/GlueSchemaRegistryConfiguration.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/main/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfiguration.java b/common/src/main/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfiguration.java index 98b32cbc..10b370c4 100644 --- a/common/src/main/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfiguration.java +++ b/common/src/main/java/com/amazonaws/services/schemaregistry/common/configs/GlueSchemaRegistryConfiguration.java @@ -46,7 +46,7 @@ public class GlueSchemaRegistryConfiguration { private AWSSchemaRegistryConstants.COMPRESSION compressionType = AWSSchemaRegistryConstants.COMPRESSION.NONE; private String endPoint; private String region; - // TODO: Remove configs that are not useful non replication use-cases. + // TODO: Remove configs that are not useful non replication use-cases // https://github.com/awslabs/aws-glue-schema-registry/issues/292 private String sourceEndPoint; private String sourceRegion; From f5f07889ed58d7b822b8c8fedd8efdcd0debadfd Mon Sep 17 00:00:00 2001 From: Subham Rakshit Date: Wed, 12 Jun 2024 15:33:03 +0100 Subject: [PATCH 09/41] Change version in pom --- cross-region-replication-converter/pom.xml | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/cross-region-replication-converter/pom.xml b/cross-region-replication-converter/pom.xml index 2ddf9798..2d7d6b41 100644 --- a/cross-region-replication-converter/pom.xml +++ b/cross-region-replication-converter/pom.xml @@ -32,7 +32,7 @@ software.amazon.glue schema-registry-parent - 1.1.15 + 1.1.16 ../pom.xml @@ -136,7 +136,7 @@ software.amazon.glue schema-registry-kafkaconnect-converter - 1.1.15 + 1.1.16 compile @@ -182,14 +182,6 @@ String - - org.apache.maven.plugins - maven-compiler-plugin - - 15 - 15 - - From 07afed048d801aad11be7eeaf734a8d9d0b32060 Mon Sep 17 00:00:00 2001 From: Subham Rakshit Date: Thu, 13 Jun 2024 21:29:11 +0100 Subject: [PATCH 10/41] Added 2nd kafka and ZK in docker compose service file --- integration-tests/docker-compose.yml | 33 ++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/integration-tests/docker-compose.yml b/integration-tests/docker-compose.yml index a7d133aa..7064939f 100644 --- a/integration-tests/docker-compose.yml +++ b/integration-tests/docker-compose.yml @@ -1,10 +1,17 @@ -version: '2' - services: zookeeper: image: 'public.ecr.aws/bitnami/zookeeper:latest' ports: - - '2181:2182' + - '2181' + container_name: local_zk + environment: + - ALLOW_ANONYMOUS_LOGIN=yes + + zookeeper2: + image: 'public.ecr.aws/bitnami/zookeeper:latest' + ports: + - '2181' + container_name: local_zk2 environment: - ALLOW_ANONYMOUS_LOGIN=yes @@ -21,6 +28,24 @@ services: - KAFKA_LISTENERS=PLAINTEXT://0.0.0.0:9092 - KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://localhost:9092 - ALLOW_PLAINTEXT_LISTENER=yes + - KAFKA_CFG_AUTO_CREATE_TOPICS_ENABLE=true + + kafka-target: + image: 'public.ecr.aws/bitnami/kafka:2.8' + ports: + - '9093:9093' + links: + - zookeeper + container_name: local_kafka_target + environment: + - KAFKA_BROKER_ID=2 + - KAFKA_CFG_ZOOKEEPER_CONNECT=zookeeper2:2181 + - ALLOW_PLAINTEXT_LISTENER=yes + - KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP=CLIENT:PLAINTEXT,EXTERNAL:PLAINTEXT + - KAFKA_CFG_LISTENERS=CLIENT://:9092,EXTERNAL://:9093 + - KAFKA_CFG_ADVERTISED_LISTENERS=CLIENT://kafka-target:9092,EXTERNAL://localhost:9093 + - KAFKA_CFG_INTER_BROKER_LISTENER_NAME=CLIENT + - KAFKA_CFG_AUTO_CREATE_TOPICS_ENABLE=true localstack: container_name: "${LOCALSTACK_DOCKER_NAME-localstack_main}" @@ -35,4 +60,4 @@ services: - PARITY_AWS_ACCESS_KEY_ID=1 volumes: - "${LOCALSTACK_VOLUME_DIR:-./volume}:/var/lib/localstack" - - "/var/run/docker.sock:/var/run/docker.sock" + - "/var/run/docker.sock:/var/run/docker.sock" \ No newline at end of file From 0c938961887ea8e549a7ec21948539436881b04f Mon Sep 17 00:00:00 2001 From: Subham Rakshit Date: Thu, 13 Jun 2024 23:26:59 +0100 Subject: [PATCH 11/41] Added mm2 connector files for standalone run --- cross-region-replication-converter/pom.xml | 4 +- .../mm2-configs/connect-standalone.properties | 41 +++++++++++++++++++ .../mirror-checkpoint-connector.properties | 13 ++++++ .../mirror-heartbeat-connector.properties | 13 ++++++ .../mirror-source-connector.properties | 37 +++++++++++++++++ 5 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 integration-tests/mm2-configs/connect-standalone.properties create mode 100644 integration-tests/mm2-configs/mirror-checkpoint-connector.properties create mode 100644 integration-tests/mm2-configs/mirror-heartbeat-connector.properties create mode 100644 integration-tests/mm2-configs/mirror-source-connector.properties diff --git a/cross-region-replication-converter/pom.xml b/cross-region-replication-converter/pom.xml index 2d7d6b41..4ea2e987 100644 --- a/cross-region-replication-converter/pom.xml +++ b/cross-region-replication-converter/pom.xml @@ -32,7 +32,7 @@ software.amazon.glue schema-registry-parent - 1.1.16 + 1.1.20 ../pom.xml @@ -136,7 +136,7 @@ software.amazon.glue schema-registry-kafkaconnect-converter - 1.1.16 + 1.1.20 compile diff --git a/integration-tests/mm2-configs/connect-standalone.properties b/integration-tests/mm2-configs/connect-standalone.properties new file mode 100644 index 00000000..e6414a1e --- /dev/null +++ b/integration-tests/mm2-configs/connect-standalone.properties @@ -0,0 +1,41 @@ +# 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. + +# These are defaults. This file just demonstrates how to override some settings. +bootstrap.servers=localhost:9092 + +# The converters specify the format of data in Kafka and how to translate it into Connect data. Every Connect user will +# need to configure these based on the format they want their data in when loaded from or stored into Kafka +key.converter=org.apache.kafka.connect.json.JsonConverter +value.converter=org.apache.kafka.connect.json.JsonConverter +# Converter-specific settings can be passed in by prefixing the Converter's setting with the converter we want to apply +# it to +key.converter.schemas.enable=true +value.converter.schemas.enable=true + +offset.storage.file.filename=/tmp/connect.offsets +# Flush much faster than normal, which is useful for testing/debugging +offset.flush.interval.ms=10000 + +# Set to a list of filesystem paths separated by commas (,) to enable class loading isolation for plugins +# (connectors, converters, transformations). The list should consist of top level directories that include +# any combination of: +# a) directories immediately containing jars with plugins and their dependencies +# b) uber-jars with plugins and their dependencies +# c) directories immediately containing the package directory structure of classes of plugins and their dependencies +# Note: symlinks will be followed to discover dependencies or plugins. +# Examples: +# plugin.path=/usr/local/share/java,/usr/local/share/kafka/plugins,/opt/connectors, +plugin.path=/Users/rakssubh/Workspace/aws-gsr diff --git a/integration-tests/mm2-configs/mirror-checkpoint-connector.properties b/integration-tests/mm2-configs/mirror-checkpoint-connector.properties new file mode 100644 index 00000000..30f45f08 --- /dev/null +++ b/integration-tests/mm2-configs/mirror-checkpoint-connector.properties @@ -0,0 +1,13 @@ +name=mm2-cpc +connector.class=org.apache.kafka.connect.mirror.MirrorCheckpointConnector +clusters=src,dst +source.cluster.alias=src +target.cluster.alias=dst +source.cluster.bootstrap.servers=localhost:9092 +target.cluster.bootstrap.servers=localhost:9093 +tasks.max=1 +key.converter= org.apache.kafka.connect.converters.ByteArrayConverter +value.converter=org.apache.kafka.connect.converters.ByteArrayConverter +replication.factor=1 +checkpoints.topic.replication.factor=1 +emit.checkpoints.interval.seconds=20 \ No newline at end of file diff --git a/integration-tests/mm2-configs/mirror-heartbeat-connector.properties b/integration-tests/mm2-configs/mirror-heartbeat-connector.properties new file mode 100644 index 00000000..a922f01d --- /dev/null +++ b/integration-tests/mm2-configs/mirror-heartbeat-connector.properties @@ -0,0 +1,13 @@ +name=mm2-hbc +connector.class=org.apache.kafka.connect.mirror.MirrorHeartbeatConnector +clusters=src,dst +source.cluster.alias=src +target.cluster.alias=dst +source.cluster.bootstrap.servers=localhost:9092 +target.cluster.bootstrap.servers=localhost:9093 +tasks.max=1 +key.converter= org.apache.kafka.connect.converters.ByteArrayConverter +value.converter=org.apache.kafka.connect.converters.ByteArrayConverter +replication.factor=1 +heartbeats.topic.replication.factor=1 +emit.heartbeats.interval.seconds=20 \ No newline at end of file diff --git a/integration-tests/mm2-configs/mirror-source-connector.properties b/integration-tests/mm2-configs/mirror-source-connector.properties new file mode 100644 index 00000000..69f2415a --- /dev/null +++ b/integration-tests/mm2-configs/mirror-source-connector.properties @@ -0,0 +1,37 @@ +name=mm2-msc +connector.class=org.apache.kafka.connect.mirror.MirrorSourceConnector +clusters=src,dst +source.cluster.alias=src +target.cluster.alias=dst +source.cluster.bootstrap.servers=localhost:9092 +target.cluster.bootstrap.servers=localhost:9093 +topics=customer +tasks.max=2 +key.converter=com.amazonaws.services.crossregion.schemaregistry.kafkaconnect.CrossRegionReplicationConverter +value.converter=com.amazonaws.services.crossregion.schemaregistry.kafkaconnect.CrossRegionReplicationConverter +replication.factor=1 +offset-syncs.topic.replication.factor=1 +sync.topic.acls.interval.seconds=20 +sync.topic.configs.interval.seconds=20 +refresh.topics.interval.seconds=20 +refresh.groups.interval.seconds=20 +consumer.group.id=mm2-msc-cons +producer.enable.idempotence=true +key.converter.schemas.enable=false +value.converter.schemas.enable=true +key.converter.source.endpoint=https://glue.us-east-1.amazonaws.com +key.converter.source.region=us-east-1 +key.converter.target.endpoint=https://glue.us-east-2.amazonaws.com +key.converter.target.region=us-east-2 +key.converter.registry.name=migration +key.converter.schemaAutoRegistrationEnabled=true +key.converter.avroRecordType=GENERIC_RECORD +value.converter.source.endpoint=https://glue.us-east-1.amazonaws.com +value.converter.source.region=us-east-1 +value.converter.target.endpoint=https://glue.us-east-2.amazonaws.com +value.converter.target.region=us-east-2 +value.converter.registry.name=migration +value.converter.schemaAutoRegistrationEnabled=true +value.converter.avroRecordType=GENERIC_RECORD +errors.log.enable=true +errors.log.include.messages=true \ No newline at end of file From d86bfcd89470b4d04378a234dd45f46aa4344d20 Mon Sep 17 00:00:00 2001 From: Subham Rakshit Date: Fri, 14 Jun 2024 22:38:14 +0100 Subject: [PATCH 12/41] Added code for scenario where message doesn't have schema in it. --- cross-region-replication-converter/pom.xml | 2 +- .../CrossRegionReplicationConverter.java | 19 +++++++++++-------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/cross-region-replication-converter/pom.xml b/cross-region-replication-converter/pom.xml index 4ea2e987..91f86e5d 100644 --- a/cross-region-replication-converter/pom.xml +++ b/cross-region-replication-converter/pom.xml @@ -20,7 +20,7 @@ 4.0.0 ${parent.groupId} - schema-registry-cross-region-kafkaconnect-converter + cross-region-replication-converter ${parent.version} AWS Cross Region Glue Schema Registry Kafka Connect Schema Replication Converter The AWS Glue Schema Registry Kafka Connect Converter enables Java developers to easily replicate diff --git a/cross-region-replication-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverter.java b/cross-region-replication-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverter.java index d97943cc..824851e1 100644 --- a/cross-region-replication-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverter.java +++ b/cross-region-replication-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverter.java @@ -4,18 +4,16 @@ import com.amazonaws.services.schemaregistry.common.configs.GlueSchemaRegistryConfiguration; import com.amazonaws.services.schemaregistry.deserializers.GlueSchemaRegistryDeserializerImpl; import com.amazonaws.services.schemaregistry.exception.AWSSchemaRegistryException; +import com.amazonaws.services.schemaregistry.exception.GlueSchemaRegistryIncompatibleDataException; import com.amazonaws.services.schemaregistry.serializers.GlueSchemaRegistrySerializerImpl; import com.amazonaws.services.schemaregistry.utils.AWSSchemaRegistryConstants; - import lombok.Data; - import org.apache.kafka.common.errors.SerializationException; import org.apache.kafka.connect.data.*; import org.apache.kafka.connect.errors.DataException; import org.apache.kafka.connect.storage.Converter; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; - import java.util.HashMap; import java.util.Map; @@ -63,21 +61,21 @@ public void configure(Map configs, boolean isKey) { Map sourceConfigs = new HashMap<>(configs); Map targetConfigs = new HashMap<>(configs); - if (configs.get(AWSSchemaRegistryConstants.AWS_SOURCE_REGION) == null){ + if (configs.get(AWSSchemaRegistryConstants.AWS_SOURCE_REGION) == null) { throw new DataException("Source Region is not provided."); - } - else if (configs.get(AWSSchemaRegistryConstants.AWS_TARGET_REGION) == null && configs.get(AWSSchemaRegistryConstants.AWS_REGION) == null){ + } else if (configs.get(AWSSchemaRegistryConstants.AWS_TARGET_REGION) == null && configs.get(AWSSchemaRegistryConstants.AWS_REGION) == null) { throw new DataException("Target Region is not provided."); } sourceConfigs.put(AWSSchemaRegistryConstants.AWS_REGION, configs.get(AWSSchemaRegistryConstants.AWS_SOURCE_REGION)); targetConfigs.put(AWSSchemaRegistryConstants.AWS_REGION, configs.get(AWSSchemaRegistryConstants.AWS_TARGET_REGION)); - serializer = new GlueSchemaRegistrySerializerImpl(credentialsProvider, new GlueSchemaRegistryConfiguration(targetConfigs)); + serializer = new GlueSchemaRegistrySerializerImpl(credentialsProvider, new GlueSchemaRegistryConfiguration(targetConfigs)); deserializer = new GlueSchemaRegistryDeserializerImpl(credentialsProvider, new GlueSchemaRegistryConfiguration(sourceConfigs)); } @Override + public byte[] fromConnectData(String topic, org.apache.kafka.connect.data.Schema schema, Object value) { if (value == null) return null; byte[] bytes = (byte[]) value; @@ -90,7 +88,12 @@ public byte[] fromConnectData(String topic, org.apache.kafka.connect.data.Schema // https://github.com/awslabs/aws-glue-schema-registry/issues/294 return serializer.encode(topic, deserializedSchema, deserializedBytes); - } catch (SerializationException | AWSSchemaRegistryException e){ + } catch(GlueSchemaRegistryIncompatibleDataException ex) { + //This exception is raised when the header bytes don't have schema id, version byte or compression byte + //This determines the data doesn't have schema information in it, so the actual message is returned. + return bytes; + } + catch (SerializationException | AWSSchemaRegistryException e) { throw new DataException("Converting Kafka Connect data to byte[] failed due to serialization/deserialization error: ", e); } } From a23c8a9025293e8876ab559b9468d9c12d3f33bb Mon Sep 17 00:00:00 2001 From: Subham Rakshit Date: Fri, 14 Jun 2024 23:09:12 +0100 Subject: [PATCH 13/41] Updated mm2 configs --- .../mm2-configs/connect-standalone.properties | 35 ++----------------- .../mirror-checkpoint-connector.properties | 2 +- .../mirror-source-connector.properties | 5 ++- 3 files changed, 6 insertions(+), 36 deletions(-) diff --git a/integration-tests/mm2-configs/connect-standalone.properties b/integration-tests/mm2-configs/connect-standalone.properties index e6414a1e..b54da091 100644 --- a/integration-tests/mm2-configs/connect-standalone.properties +++ b/integration-tests/mm2-configs/connect-standalone.properties @@ -1,41 +1,12 @@ -# 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. +bootstrap.servers=localhost:9093 -# These are defaults. This file just demonstrates how to override some settings. -bootstrap.servers=localhost:9092 +key.converter=org.apache.kafka.connect.converters.ByteArrayConverter +value.converter=org.apache.kafka.connect.converters.ByteArrayConverter -# The converters specify the format of data in Kafka and how to translate it into Connect data. Every Connect user will -# need to configure these based on the format they want their data in when loaded from or stored into Kafka -key.converter=org.apache.kafka.connect.json.JsonConverter -value.converter=org.apache.kafka.connect.json.JsonConverter -# Converter-specific settings can be passed in by prefixing the Converter's setting with the converter we want to apply -# it to key.converter.schemas.enable=true value.converter.schemas.enable=true offset.storage.file.filename=/tmp/connect.offsets -# Flush much faster than normal, which is useful for testing/debugging offset.flush.interval.ms=10000 -# Set to a list of filesystem paths separated by commas (,) to enable class loading isolation for plugins -# (connectors, converters, transformations). The list should consist of top level directories that include -# any combination of: -# a) directories immediately containing jars with plugins and their dependencies -# b) uber-jars with plugins and their dependencies -# c) directories immediately containing the package directory structure of classes of plugins and their dependencies -# Note: symlinks will be followed to discover dependencies or plugins. -# Examples: -# plugin.path=/usr/local/share/java,/usr/local/share/kafka/plugins,/opt/connectors, plugin.path=/Users/rakssubh/Workspace/aws-gsr diff --git a/integration-tests/mm2-configs/mirror-checkpoint-connector.properties b/integration-tests/mm2-configs/mirror-checkpoint-connector.properties index 30f45f08..2cc00d95 100644 --- a/integration-tests/mm2-configs/mirror-checkpoint-connector.properties +++ b/integration-tests/mm2-configs/mirror-checkpoint-connector.properties @@ -6,7 +6,7 @@ target.cluster.alias=dst source.cluster.bootstrap.servers=localhost:9092 target.cluster.bootstrap.servers=localhost:9093 tasks.max=1 -key.converter= org.apache.kafka.connect.converters.ByteArrayConverter +key.converter=org.apache.kafka.connect.converters.ByteArrayConverter value.converter=org.apache.kafka.connect.converters.ByteArrayConverter replication.factor=1 checkpoints.topic.replication.factor=1 diff --git a/integration-tests/mm2-configs/mirror-source-connector.properties b/integration-tests/mm2-configs/mirror-source-connector.properties index 69f2415a..e71db201 100644 --- a/integration-tests/mm2-configs/mirror-source-connector.properties +++ b/integration-tests/mm2-configs/mirror-source-connector.properties @@ -5,14 +5,13 @@ source.cluster.alias=src target.cluster.alias=dst source.cluster.bootstrap.servers=localhost:9092 target.cluster.bootstrap.servers=localhost:9093 -topics=customer +topics=^customer[\\w]* tasks.max=2 key.converter=com.amazonaws.services.crossregion.schemaregistry.kafkaconnect.CrossRegionReplicationConverter value.converter=com.amazonaws.services.crossregion.schemaregistry.kafkaconnect.CrossRegionReplicationConverter replication.factor=1 offset-syncs.topic.replication.factor=1 -sync.topic.acls.interval.seconds=20 -sync.topic.configs.interval.seconds=20 +sync.topic.acls.enabled=false refresh.topics.interval.seconds=20 refresh.groups.interval.seconds=20 consumer.group.id=mm2-msc-cons From 13db222f374f166732c8ca7f6462265f2517065d Mon Sep 17 00:00:00 2001 From: Subham Rakshit Date: Sat, 15 Jun 2024 00:30:47 +0100 Subject: [PATCH 14/41] Unit test to show when data doesn't have schema, it still gets replicated as-is --- .../CrossRegionReplicationConverterTest.java | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/cross-region-replication-converter/src/test/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverterTest.java b/cross-region-replication-converter/src/test/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverterTest.java index 2fd278a8..bf917d56 100644 --- a/cross-region-replication-converter/src/test/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverterTest.java +++ b/cross-region-replication-converter/src/test/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverterTest.java @@ -1,10 +1,9 @@ package com.amazonaws.services.crossregion.schemaregistry.kafkaconnect; import com.amazonaws.services.schemaregistry.common.Schema; -import com.amazonaws.services.schemaregistry.deserializers.GlueSchemaRegistryDeserializationFacade; import com.amazonaws.services.schemaregistry.deserializers.GlueSchemaRegistryDeserializerImpl; import com.amazonaws.services.schemaregistry.exception.AWSSchemaRegistryException; -import com.amazonaws.services.schemaregistry.serializers.GlueSchemaRegistrySerializationFacade; +import com.amazonaws.services.schemaregistry.exception.GlueSchemaRegistryIncompatibleDataException; import com.amazonaws.services.schemaregistry.serializers.GlueSchemaRegistrySerializerImpl; import com.amazonaws.services.schemaregistry.utils.AWSSchemaRegistryConstants; import com.amazonaws.services.schemaregistry.utils.AvroRecordType; @@ -28,8 +27,7 @@ import java.util.Map; import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; /** * Unit tests for testing RegisterSchema class. @@ -153,14 +151,7 @@ public void testConverter_fromConnectData_deserializer_avroSchema_throwsExceptio */ @Test public void testConverter_fromConnectData_avroSchema_succeeds() { - String schemaDefinition = """ - {"namespace": "com.amazonaws.services.schemaregistry.serializers.avro", - "type": "record", - "name": "payment", - "fields": [ - {"name": "id", "type": "string"}, - {"name": "id_6", "type": "double"} - ]}"""; + String schemaDefinition = "{\"namespace\":\"com.amazonaws.services.schemaregistry.serializers.avro\",\"type\":\"record\",\"name\":\"payment\",\"fields\":[{\"name\":\"id\",\"type\":\"string\"},{\"name\":\"id_6\",\"type\":\"double\"}]}"; Schema testSchema = new Schema(schemaDefinition, DataFormat.AVRO.name(), testTopic); Struct expected = createStructRecord(); doReturn(genericBytes). @@ -225,6 +216,16 @@ public void testConverter_fromConnectData_jsonSchema_succeeds() { assertEquals(converter.fromConnectData(testTopic, expected.schema(), jsonBytes), ENCODED_DATA); } + /** + * Test Converter when message without schema is replicated. + */ + @Test + public void testConverter_fromConnectData_noSchema_succeeds() { + Struct expected = createStructRecord(); + when(deserializer.getData(genericBytes)).thenThrow(new GlueSchemaRegistryIncompatibleDataException("No schema in message")); + assertEquals(converter.fromConnectData(testTopic, expected.schema(), genericBytes), genericBytes); + } + /** * Test Converter when serializer throws exception with protobuf schema. */ From 925ebdd869a5d8562564ed078abd0dcf09771944 Mon Sep 17 00:00:00 2001 From: Subham Rakshit Date: Sat, 15 Jun 2024 02:57:59 +0100 Subject: [PATCH 15/41] Fixed some issues in the compose file and added mm2 --- integration-tests/docker-compose.yml | 60 ++++++++++++++++++---------- 1 file changed, 39 insertions(+), 21 deletions(-) diff --git a/integration-tests/docker-compose.yml b/integration-tests/docker-compose.yml index 7064939f..949bd3ef 100644 --- a/integration-tests/docker-compose.yml +++ b/integration-tests/docker-compose.yml @@ -1,17 +1,13 @@ services: - zookeeper: + zk1: image: 'public.ecr.aws/bitnami/zookeeper:latest' - ports: - - '2181' - container_name: local_zk + container_name: zk1 environment: - ALLOW_ANONYMOUS_LOGIN=yes - zookeeper2: + zk2: image: 'public.ecr.aws/bitnami/zookeeper:latest' - ports: - - '2181' - container_name: local_zk2 + container_name: zk2 environment: - ALLOW_ANONYMOUS_LOGIN=yes @@ -19,33 +15,46 @@ services: image: 'public.ecr.aws/bitnami/kafka:2.8' ports: - '9092:9092' - links: - - zookeeper - container_name: local_kafka + container_name: kafka environment: - KAFKA_BROKER_ID=1 - - KAFKA_CFG_ZOOKEEPER_CONNECT=zookeeper:2181 - - KAFKA_LISTENERS=PLAINTEXT://0.0.0.0:9092 - - KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://localhost:9092 + - KAFKA_CFG_ZOOKEEPER_CONNECT=zk1:2181 - ALLOW_PLAINTEXT_LISTENER=yes + - KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP=CLIENT:PLAINTEXT,EXTERNAL:PLAINTEXT + - KAFKA_CFG_LISTENERS=CLIENT://:29092,EXTERNAL://:9092 + - KAFKA_CFG_ADVERTISED_LISTENERS=CLIENT://kafka:29092,EXTERNAL://localhost:9092 + - KAFKA_CFG_INTER_BROKER_LISTENER_NAME=CLIENT - KAFKA_CFG_AUTO_CREATE_TOPICS_ENABLE=true kafka-target: image: 'public.ecr.aws/bitnami/kafka:2.8' ports: - '9093:9093' - links: - - zookeeper - container_name: local_kafka_target + container_name: kafka_target environment: - KAFKA_BROKER_ID=2 - - KAFKA_CFG_ZOOKEEPER_CONNECT=zookeeper2:2181 + - KAFKA_CFG_ZOOKEEPER_CONNECT=zk2:2181 - ALLOW_PLAINTEXT_LISTENER=yes - KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP=CLIENT:PLAINTEXT,EXTERNAL:PLAINTEXT - - KAFKA_CFG_LISTENERS=CLIENT://:9092,EXTERNAL://:9093 - - KAFKA_CFG_ADVERTISED_LISTENERS=CLIENT://kafka-target:9092,EXTERNAL://localhost:9093 + - KAFKA_CFG_LISTENERS=CLIENT://:29092,EXTERNAL://:9093 + - KAFKA_CFG_ADVERTISED_LISTENERS=CLIENT://kafka-target:29092,EXTERNAL://localhost:9093 - KAFKA_CFG_INTER_BROKER_LISTENER_NAME=CLIENT - KAFKA_CFG_AUTO_CREATE_TOPICS_ENABLE=true + + mirrormaker: + build: ./mirrormaker + container_name: mm2 + environment: + - ALLOW_PLAINTEXT_LISTENER=yes + - SOURCE=kafka:29092 + - DESTINATION=kafka-target:29092 + - TOPICS=customer + - ACLS_ENABLED=false + volumes: + - glue-schema-registry-plugins:/opt/plugins + depends_on: + - kafka + - kafka-target localstack: container_name: "${LOCALSTACK_DOCKER_NAME-localstack_main}" @@ -60,4 +69,13 @@ services: - PARITY_AWS_ACCESS_KEY_ID=1 volumes: - "${LOCALSTACK_VOLUME_DIR:-./volume}:/var/lib/localstack" - - "/var/run/docker.sock:/var/run/docker.sock" \ No newline at end of file + - "/var/run/docker.sock:/var/run/docker.sock" + +volumes: + glue-schema-registry-plugins: + driver: local + driver_opts: + type: none + device: /Users/rakssubh/Workspace/aws-gsr-subham + o: bind + From 66a847db797a324b4b30bc58683aad3ca86bbab9 Mon Sep 17 00:00:00 2001 From: Subham Rakshit Date: Sat, 15 Jun 2024 03:04:27 +0100 Subject: [PATCH 16/41] Dockerfile and mm2 config for mm2 container --- integration-tests/docker-compose.yml | 14 +++++------ integration-tests/mirrormaker/Dockerfile | 25 +++++++++++++++++++ .../mm2-configs/connect-standalone.properties | 4 +-- .../mirror-checkpoint-connector.properties | 4 +-- .../mirror-heartbeat-connector.properties | 4 +-- .../mirror-source-connector.properties | 6 ++--- integration-tests/mirrormaker/run.sh | 14 +++++++++++ 7 files changed, 55 insertions(+), 16 deletions(-) create mode 100644 integration-tests/mirrormaker/Dockerfile rename integration-tests/{ => mirrormaker}/mm2-configs/connect-standalone.properties (78%) rename integration-tests/{ => mirrormaker}/mm2-configs/mirror-checkpoint-connector.properties (80%) rename integration-tests/{ => mirrormaker}/mm2-configs/mirror-heartbeat-connector.properties (80%) rename integration-tests/{ => mirrormaker}/mm2-configs/mirror-source-connector.properties (91%) create mode 100644 integration-tests/mirrormaker/run.sh diff --git a/integration-tests/docker-compose.yml b/integration-tests/docker-compose.yml index 949bd3ef..c68ddf4e 100644 --- a/integration-tests/docker-compose.yml +++ b/integration-tests/docker-compose.yml @@ -1,13 +1,13 @@ services: - zk1: + zookeeper-kafka: image: 'public.ecr.aws/bitnami/zookeeper:latest' - container_name: zk1 + container_name: zk-kafka environment: - ALLOW_ANONYMOUS_LOGIN=yes - - zk2: + + zookeeper-kafka-target: image: 'public.ecr.aws/bitnami/zookeeper:latest' - container_name: zk2 + container_name: zk-kafka-target environment: - ALLOW_ANONYMOUS_LOGIN=yes @@ -18,7 +18,7 @@ services: container_name: kafka environment: - KAFKA_BROKER_ID=1 - - KAFKA_CFG_ZOOKEEPER_CONNECT=zk1:2181 + - KAFKA_CFG_ZOOKEEPER_CONNECT=zookeeper-kafka:2181 - ALLOW_PLAINTEXT_LISTENER=yes - KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP=CLIENT:PLAINTEXT,EXTERNAL:PLAINTEXT - KAFKA_CFG_LISTENERS=CLIENT://:29092,EXTERNAL://:9092 @@ -33,7 +33,7 @@ services: container_name: kafka_target environment: - KAFKA_BROKER_ID=2 - - KAFKA_CFG_ZOOKEEPER_CONNECT=zk2:2181 + - KAFKA_CFG_ZOOKEEPER_CONNECT=zookeeper-kafka-target:2181 - ALLOW_PLAINTEXT_LISTENER=yes - KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP=CLIENT:PLAINTEXT,EXTERNAL:PLAINTEXT - KAFKA_CFG_LISTENERS=CLIENT://:29092,EXTERNAL://:9093 diff --git a/integration-tests/mirrormaker/Dockerfile b/integration-tests/mirrormaker/Dockerfile new file mode 100644 index 00000000..6b3441bf --- /dev/null +++ b/integration-tests/mirrormaker/Dockerfile @@ -0,0 +1,25 @@ +FROM public.ecr.aws/bitnami/kafka:2.8 +USER root +RUN install_packages gettext + +RUN mkdir -p /opt/plugins +RUN chown 1234 /opt/plugins + +ADD ./mm2-configs/connect-standalone.properties /opt/mm2/connect-standalone.properties +ADD ./mm2-configs/mirror-checkpoint-connector.properties /opt/mm2/mirror-checkpoint-connector.properties +ADD ./mm2-configs/mirror-heartbeat-connector.properties /opt/mm2/mirror-heartbeat-connector.properties +ADD ./mm2-configs/mirror-source-connector.properties /opt/mm2/mirror-source-connector.properties + +ADD ./run.sh /opt/mm2/run.sh +RUN chmod +x /opt/mm2/run.sh + +RUN mkdir -p /var/run/mm2 +RUN chown 1234 /var/run/mm2 + +ENV TOPICS .* +ENV SOURCE "localhost:9092" +ENV DESTINATION "localhost:9093" +ENV ACLS_ENABLED "false" + +USER 1234 +CMD /opt/mm2/run.sh diff --git a/integration-tests/mm2-configs/connect-standalone.properties b/integration-tests/mirrormaker/mm2-configs/connect-standalone.properties similarity index 78% rename from integration-tests/mm2-configs/connect-standalone.properties rename to integration-tests/mirrormaker/mm2-configs/connect-standalone.properties index b54da091..97e2fed5 100644 --- a/integration-tests/mm2-configs/connect-standalone.properties +++ b/integration-tests/mirrormaker/mm2-configs/connect-standalone.properties @@ -1,4 +1,4 @@ -bootstrap.servers=localhost:9093 +bootstrap.servers=${DESTINATION} key.converter=org.apache.kafka.connect.converters.ByteArrayConverter value.converter=org.apache.kafka.connect.converters.ByteArrayConverter @@ -9,4 +9,4 @@ value.converter.schemas.enable=true offset.storage.file.filename=/tmp/connect.offsets offset.flush.interval.ms=10000 -plugin.path=/Users/rakssubh/Workspace/aws-gsr +plugin.path=/opt/plugins/ diff --git a/integration-tests/mm2-configs/mirror-checkpoint-connector.properties b/integration-tests/mirrormaker/mm2-configs/mirror-checkpoint-connector.properties similarity index 80% rename from integration-tests/mm2-configs/mirror-checkpoint-connector.properties rename to integration-tests/mirrormaker/mm2-configs/mirror-checkpoint-connector.properties index 2cc00d95..10792369 100644 --- a/integration-tests/mm2-configs/mirror-checkpoint-connector.properties +++ b/integration-tests/mirrormaker/mm2-configs/mirror-checkpoint-connector.properties @@ -3,8 +3,8 @@ connector.class=org.apache.kafka.connect.mirror.MirrorCheckpointConnector clusters=src,dst source.cluster.alias=src target.cluster.alias=dst -source.cluster.bootstrap.servers=localhost:9092 -target.cluster.bootstrap.servers=localhost:9093 +source.cluster.bootstrap.servers=${SOURCE} +target.cluster.bootstrap.servers=${DESTINATION} tasks.max=1 key.converter=org.apache.kafka.connect.converters.ByteArrayConverter value.converter=org.apache.kafka.connect.converters.ByteArrayConverter diff --git a/integration-tests/mm2-configs/mirror-heartbeat-connector.properties b/integration-tests/mirrormaker/mm2-configs/mirror-heartbeat-connector.properties similarity index 80% rename from integration-tests/mm2-configs/mirror-heartbeat-connector.properties rename to integration-tests/mirrormaker/mm2-configs/mirror-heartbeat-connector.properties index a922f01d..176bd923 100644 --- a/integration-tests/mm2-configs/mirror-heartbeat-connector.properties +++ b/integration-tests/mirrormaker/mm2-configs/mirror-heartbeat-connector.properties @@ -3,8 +3,8 @@ connector.class=org.apache.kafka.connect.mirror.MirrorHeartbeatConnector clusters=src,dst source.cluster.alias=src target.cluster.alias=dst -source.cluster.bootstrap.servers=localhost:9092 -target.cluster.bootstrap.servers=localhost:9093 +source.cluster.bootstrap.servers=${SOURCE} +target.cluster.bootstrap.servers=${DESTINATION} tasks.max=1 key.converter= org.apache.kafka.connect.converters.ByteArrayConverter value.converter=org.apache.kafka.connect.converters.ByteArrayConverter diff --git a/integration-tests/mm2-configs/mirror-source-connector.properties b/integration-tests/mirrormaker/mm2-configs/mirror-source-connector.properties similarity index 91% rename from integration-tests/mm2-configs/mirror-source-connector.properties rename to integration-tests/mirrormaker/mm2-configs/mirror-source-connector.properties index e71db201..9137c053 100644 --- a/integration-tests/mm2-configs/mirror-source-connector.properties +++ b/integration-tests/mirrormaker/mm2-configs/mirror-source-connector.properties @@ -3,15 +3,15 @@ connector.class=org.apache.kafka.connect.mirror.MirrorSourceConnector clusters=src,dst source.cluster.alias=src target.cluster.alias=dst -source.cluster.bootstrap.servers=localhost:9092 -target.cluster.bootstrap.servers=localhost:9093 +source.cluster.bootstrap.servers=${SOURCE} +target.cluster.bootstrap.servers=${DESTINATION} topics=^customer[\\w]* tasks.max=2 key.converter=com.amazonaws.services.crossregion.schemaregistry.kafkaconnect.CrossRegionReplicationConverter value.converter=com.amazonaws.services.crossregion.schemaregistry.kafkaconnect.CrossRegionReplicationConverter replication.factor=1 offset-syncs.topic.replication.factor=1 -sync.topic.acls.enabled=false +sync.topic.acls.enabled=${ACLS_ENABLED} refresh.topics.interval.seconds=20 refresh.groups.interval.seconds=20 consumer.group.id=mm2-msc-cons diff --git a/integration-tests/mirrormaker/run.sh b/integration-tests/mirrormaker/run.sh new file mode 100644 index 00000000..8d6c516f --- /dev/null +++ b/integration-tests/mirrormaker/run.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -e + +envsubst < /opt/mm2/connect-standalone.properties > /var/run/mm2/connect-standalone.properties +envsubst < /opt/mm2/mirror-checkpoint-connector.properties > /var/run/mm2/mirror-checkpoint-connector.properties +envsubst < /opt/mm2/mirror-heartbeat-connector.properties > /var/run/mm2/mirror-heartbeat-connector.properties +envsubst < /opt/mm2/mirror-source-connector.properties > /var/run/mm2/mirror-source-connector.properties + +/opt/bitnami/kafka/bin/connect-standalone.sh \ + /var/run/mm2/connect-standalone.properties \ + /var/run/mm2/mirror-heartbeat-connector.properties \ + /var/run/mm2/mirror-checkpoint-connector.properties \ + /var/run/mm2/mirror-source-connector.properties \ No newline at end of file From 0aa94f1641fc6edb94f26e61bbd24514e4c4978a Mon Sep 17 00:00:00 2001 From: Subham Rakshit Date: Tue, 18 Jun 2024 10:02:46 +0100 Subject: [PATCH 17/41] Added integration tests for schema replication --- integration-tests/docker-compose.yml | 4 ++-- .../mm2-configs/mirror-source-connector.properties | 10 +++++----- .../GlueSchemaRegistryConnectionProperties.java | 4 ++++ 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/integration-tests/docker-compose.yml b/integration-tests/docker-compose.yml index c68ddf4e..ea31f96c 100644 --- a/integration-tests/docker-compose.yml +++ b/integration-tests/docker-compose.yml @@ -48,7 +48,7 @@ services: - ALLOW_PLAINTEXT_LISTENER=yes - SOURCE=kafka:29092 - DESTINATION=kafka-target:29092 - - TOPICS=customer + - TOPICS=^SchemaRegistryTests.* - ACLS_ENABLED=false volumes: - glue-schema-registry-plugins:/opt/plugins @@ -76,6 +76,6 @@ volumes: driver: local driver_opts: type: none - device: /Users/rakssubh/Workspace/aws-gsr-subham + device: ../ o: bind diff --git a/integration-tests/mirrormaker/mm2-configs/mirror-source-connector.properties b/integration-tests/mirrormaker/mm2-configs/mirror-source-connector.properties index 9137c053..b77ae55f 100644 --- a/integration-tests/mirrormaker/mm2-configs/mirror-source-connector.properties +++ b/integration-tests/mirrormaker/mm2-configs/mirror-source-connector.properties @@ -5,7 +5,7 @@ source.cluster.alias=src target.cluster.alias=dst source.cluster.bootstrap.servers=${SOURCE} target.cluster.bootstrap.servers=${DESTINATION} -topics=^customer[\\w]* +topics=${TOPICS} tasks.max=2 key.converter=com.amazonaws.services.crossregion.schemaregistry.kafkaconnect.CrossRegionReplicationConverter value.converter=com.amazonaws.services.crossregion.schemaregistry.kafkaconnect.CrossRegionReplicationConverter @@ -22,15 +22,15 @@ key.converter.source.endpoint=https://glue.us-east-1.amazonaws.com key.converter.source.region=us-east-1 key.converter.target.endpoint=https://glue.us-east-2.amazonaws.com key.converter.target.region=us-east-2 -key.converter.registry.name=migration +key.converter.registry.name=default-registry key.converter.schemaAutoRegistrationEnabled=true -key.converter.avroRecordType=GENERIC_RECORD +#key.converter.avroRecordType=GENERIC_RECORD value.converter.source.endpoint=https://glue.us-east-1.amazonaws.com value.converter.source.region=us-east-1 value.converter.target.endpoint=https://glue.us-east-2.amazonaws.com value.converter.target.region=us-east-2 -value.converter.registry.name=migration +value.converter.registry.name=default-registry value.converter.schemaAutoRegistrationEnabled=true -value.converter.avroRecordType=GENERIC_RECORD +#value.converter.avroRecordType=GENERIC_RECORD errors.log.enable=true errors.log.include.messages=true \ No newline at end of file diff --git a/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/properties/GlueSchemaRegistryConnectionProperties.java b/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/properties/GlueSchemaRegistryConnectionProperties.java index aff3527b..548363ba 100644 --- a/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/properties/GlueSchemaRegistryConnectionProperties.java +++ b/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/properties/GlueSchemaRegistryConnectionProperties.java @@ -19,4 +19,8 @@ public interface GlueSchemaRegistryConnectionProperties { // Glue Service Endpoint String REGION = Regions.getCurrentRegion() == null ? "us-east-2" : Regions.getCurrentRegion().getName().toLowerCase(); String ENDPOINT = String.format("https://glue.%s.amazonaws.com", REGION); + String SRC_REGION = Regions.getCurrentRegion() == null ? "us-east-1" : Regions.getCurrentRegion().getName().toLowerCase(); + String SRC_ENDPOINT = String.format("https://glue.%s.amazonaws.com", SRC_REGION); + String DEST_REGION = "us-east-2"; + String DEST_ENDPOINT = String.format("https://glue.%s.amazonaws.com", DEST_REGION); } From f296fd0032199d950cf874e84fc6e0c5ce627706 Mon Sep 17 00:00:00 2001 From: Subham Rakshit Date: Tue, 18 Jun 2024 13:24:20 +0100 Subject: [PATCH 18/41] Added integration tests for schema replication --- .../ConsumerProperties.java | 28 ++ ...istrySchemaReplicationIntegrationTest.java | 228 ++++++++++++ .../KafkaClusterHelper.java | 27 ++ .../schema_replication/KafkaHelper.java | 343 ++++++++++++++++++ .../LocalKafkaClusterHelper.java | 49 +++ .../ProducerProperties.java | 34 ++ 6 files changed, 709 insertions(+) create mode 100644 integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/ConsumerProperties.java create mode 100644 integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/GlueSchemaRegistrySchemaReplicationIntegrationTest.java create mode 100644 integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/KafkaClusterHelper.java create mode 100644 integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/KafkaHelper.java create mode 100644 integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/LocalKafkaClusterHelper.java create mode 100644 integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/ProducerProperties.java diff --git a/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/ConsumerProperties.java b/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/ConsumerProperties.java new file mode 100644 index 00000000..870325a3 --- /dev/null +++ b/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/ConsumerProperties.java @@ -0,0 +1,28 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. + * Licensed 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 com.amazonaws.services.schemaregistry.integrationtests.schema_replication; + +import com.amazonaws.services.schemaregistry.integrationtests.properties.GlueSchemaRegistryConnectionProperties; +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +public class ConsumerProperties implements GlueSchemaRegistryConnectionProperties { + private String topicName; + private String avroRecordType; + private String protobufMessageType; +} + diff --git a/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/GlueSchemaRegistrySchemaReplicationIntegrationTest.java b/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/GlueSchemaRegistrySchemaReplicationIntegrationTest.java new file mode 100644 index 00000000..bba2541f --- /dev/null +++ b/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/GlueSchemaRegistrySchemaReplicationIntegrationTest.java @@ -0,0 +1,228 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. + * Licensed 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 com.amazonaws.services.schemaregistry.integrationtests.schema_replication; + +import com.amazonaws.services.schemaregistry.deserializers.protobuf.ProtobufClassName; +import com.amazonaws.services.schemaregistry.integrationtests.generators.*; +import com.amazonaws.services.schemaregistry.integrationtests.properties.GlueSchemaRegistryConnectionProperties; +import com.amazonaws.services.schemaregistry.serializers.json.JsonDataWithSchema; +import com.amazonaws.services.schemaregistry.utils.AWSSchemaRegistryConstants; +import com.amazonaws.services.schemaregistry.utils.AvroRecordType; +import com.amazonaws.services.schemaregistry.utils.ProtobufMessageType; +import com.google.protobuf.DynamicMessage; +import com.google.protobuf.Message; +import lombok.extern.slf4j.Slf4j; +import org.apache.avro.generic.GenericRecord; +import org.apache.commons.lang3.RandomStringUtils; +import org.apache.commons.lang3.tuple.Pair; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.junit.jupiter.api.AfterAll; +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.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.glue.GlueClient; +import software.amazon.awssdk.services.glue.model.Compatibility; +import software.amazon.awssdk.services.glue.model.DataFormat; +import software.amazon.awssdk.services.glue.model.DeleteSchemaRequest; +import software.amazon.awssdk.services.glue.model.SchemaId; + +import java.net.URI; +import java.net.URISyntaxException; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * The test class for schema replication related tests for Glue Schema Registry + */ +@Slf4j +public class GlueSchemaRegistrySchemaReplicationIntegrationTest { + private static final String SRC_CLUSTER_ALIAS = "src"; + private static final String TOPIC_NAME_PREFIX = "SchemaRegistryTests"; + private static final String SCHEMA_REGISTRY_SRC_ENDPOINT_OVERRIDE = GlueSchemaRegistryConnectionProperties.SRC_ENDPOINT; + private static final String SCHEMA_REGISTRY_DEST_ENDPOINT_OVERRIDE = GlueSchemaRegistryConnectionProperties.DEST_ENDPOINT; + private static final String SRC_REGION = GlueSchemaRegistryConnectionProperties.SRC_REGION; + private static final String DEST_REGION = GlueSchemaRegistryConnectionProperties.DEST_REGION; + private static final String RECORD_TYPE = "GENERIC_RECORD"; + private static LocalKafkaClusterHelper srcKafkaClusterHelper = new LocalKafkaClusterHelper(); + private static LocalKafkaClusterHelper destKafkaClusterHelper = new LocalKafkaClusterHelper(); + private static AwsCredentialsProvider awsCredentialsProvider = DefaultCredentialsProvider.builder() + .build(); + private static List schemasToCleanUp = new ArrayList<>(); + private final TestDataGeneratorFactory testDataGeneratorFactory = new TestDataGeneratorFactory(); + + private static Stream testArgumentsProvider() { + Stream.Builder argumentBuilder = Stream.builder(); + for (DataFormat dataFormat : DataFormat.knownValues()) { + argumentBuilder.add(Arguments.of(dataFormat, RECORD_TYPE, Compatibility.BACKWARD)); + } + return argumentBuilder.build(); + } + + private static Pair createAndGetKafkaHelper(String topicNamePrefix) throws Exception { + final String topic = String.format("%s-%s-%s", topicNamePrefix, Instant.now() + .atOffset(ZoneOffset.UTC) + .format(DateTimeFormatter.ofPattern("yy-MM-dd-HH-mm")), RandomStringUtils.randomAlphanumeric(4)); + + final String srcBootstrapString = srcKafkaClusterHelper.getSrcClusterBootstrapString(); + final KafkaHelper kafkaHelper = new KafkaHelper(srcBootstrapString, srcKafkaClusterHelper.getOrCreateCluster()); + kafkaHelper.createTopic(topic, srcKafkaClusterHelper.getNumberOfPartitions(), srcKafkaClusterHelper.getReplicationFactor()); + return Pair.of(topic, kafkaHelper); + } + + @Test + public void testProduceConsumeWithoutGlueSchemaRegistry() throws Exception { + log.info("Starting the test for producing and consuming messages via Kafka ..."); + + final Pair srcKafkaHelperPair = createAndGetKafkaHelper(TOPIC_NAME_PREFIX); + String topic = srcKafkaHelperPair.getKey(); + KafkaHelper srcKafkaHelper = srcKafkaHelperPair.getValue(); + KafkaHelper destKafkaHelper = new KafkaHelper(destKafkaClusterHelper.getDestClusterBootstrapString(), destKafkaClusterHelper.getOrCreateCluster()); + + final int recordsProduced = 20; + srcKafkaHelper.doProduce(topic, recordsProduced); + + //Delay added to allow MM2 copy the data to destination cluster + //before consuming the records from the destination cluster + Thread.sleep(5000); + + ConsumerProperties consumerProperties = ConsumerProperties.builder() + .topicName(String.format("%s.%s",SRC_CLUSTER_ALIAS, topic)) + .build(); + + int recordsConsumed = destKafkaHelper.doConsume(consumerProperties); + log.info("Producing {} records, and consuming {} records", recordsProduced, recordsConsumed); + + assertEquals(recordsConsumed, recordsProduced); + log.info("Finish the test for producing/consuming messages via Kafka."); + } + + @ParameterizedTest + @MethodSource("testArgumentsProvider") + public void testProduceConsumeWithSchemaRegistryForAllThreeDataFormats(final DataFormat dataFormat, + final AvroRecordType avroRecordType, + final Compatibility compatibility) throws Exception { + log.info("Starting the test for producing and consuming {} messages via Kafka ...", dataFormat.name()); + final Pair srcKafkaHelperPair = createAndGetKafkaHelper(TOPIC_NAME_PREFIX); + String topic = srcKafkaHelperPair.getKey(); + KafkaHelper srcKafkaHelper = srcKafkaHelperPair.getValue(); + KafkaHelper destKafkaHelper = new KafkaHelper(destKafkaClusterHelper.getDestClusterBootstrapString(), destKafkaClusterHelper.getOrCreateCluster()); + + TestDataGenerator testDataGenerator = testDataGeneratorFactory.getInstance( + TestDataGeneratorType.valueOf(dataFormat, avroRecordType, compatibility)); + List records = testDataGenerator.createRecords(); + + String schemaName = String.format("%s-%s", topic, dataFormat.name()); + schemasToCleanUp.add(schemaName); + + ProducerProperties producerProperties = ProducerProperties.builder() + .topicName(topic) + .schemaName(schemaName) + .dataFormat(dataFormat.name()) + .compatibilityType(compatibility.name()) + .autoRegistrationEnabled("true") + .build(); + + List> producerRecords = + srcKafkaHelper.doProduceRecords(producerProperties, records); + + //Delay added to allow MM2 copy the data to destination cluster + //before consuming the records from the destination cluster + Thread.sleep(3000); + + ConsumerProperties.ConsumerPropertiesBuilder consumerPropertiesBuilder = ConsumerProperties.builder() + .topicName(String.format("%s.%s",SRC_CLUSTER_ALIAS, topic)); + + consumerPropertiesBuilder.protobufMessageType(ProtobufMessageType.DYNAMIC_MESSAGE.getName()); + consumerPropertiesBuilder.avroRecordType(avroRecordType.getName()); // Only required for the case of AVRO + + List> consumerRecords = destKafkaHelper.doConsumeRecords(consumerPropertiesBuilder.build()); + + assertRecordsEquality(producerRecords, consumerRecords); + log.info("Finished test for producing/consuming {} messages via Kafka.", dataFormat.name()); + } + + @AfterAll + public static void tearDown() throws URISyntaxException { + log.info("Starting Clean-up of schemas created with GSR."); + GlueClient glueClientSrc = GlueClient.builder() + .credentialsProvider(awsCredentialsProvider) + .region(Region.of(SRC_REGION)) + .endpointOverride(new URI(SCHEMA_REGISTRY_SRC_ENDPOINT_OVERRIDE)) + .httpClient(UrlConnectionHttpClient.builder() + .build()) + .build(); + GlueClient glueClientDest = GlueClient.builder() + .credentialsProvider(awsCredentialsProvider) + .region(Region.of(DEST_REGION)) + .endpointOverride(new URI(SCHEMA_REGISTRY_DEST_ENDPOINT_OVERRIDE)) + .httpClient(UrlConnectionHttpClient.builder() + .build()) + .build(); + + for (String schemaName : schemasToCleanUp) { + log.info("Cleaning up schema {}..", schemaName); + DeleteSchemaRequest deleteSchemaRequest = DeleteSchemaRequest.builder() + .schemaId(SchemaId.builder() + .registryName("default-registry") + .schemaName(schemaName) + .build()) + .build(); + + glueClientSrc.deleteSchema(deleteSchemaRequest); + glueClientDest.deleteSchema(deleteSchemaRequest); + } + + log.info("Finished Cleaning up {} schemas created with GSR.", schemasToCleanUp.size()); + } + + private void assertRecordsEquality(List> producerRecords, + List> consumerRecords) { + assertThat(producerRecords.size(), is(equalTo(consumerRecords.size()))); + Map producerRecordsMap = producerRecords.stream() + .collect(Collectors.toMap(ProducerRecord::key, ProducerRecord::value)); + + for (ConsumerRecord consumerRecord : consumerRecords) { + assertThat(producerRecordsMap, hasKey(consumerRecord.key())); + if (consumerRecord.value() instanceof DynamicMessage) { + assertDynamicRecords(consumerRecord, producerRecordsMap); + } else { + assertThat(consumerRecord.value(), is(equalTo(producerRecordsMap.get(consumerRecord.key())))); + } + } + } + + private void assertDynamicRecords(ConsumerRecord consumerRecord, Map producerRecordsMap) { + DynamicMessage consumerDynamicMessage = (DynamicMessage) consumerRecord.value(); + Message producerDynamicMessage = (Message) producerRecordsMap.get(consumerRecord.key()); + //In case of DynamicMessage de-serialization, we cannot equate them to POJO records, + //so we check for their byte equality. + assertThat(consumerDynamicMessage.toByteArray(), is(producerDynamicMessage.toByteArray())); + } +} diff --git a/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/KafkaClusterHelper.java b/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/KafkaClusterHelper.java new file mode 100644 index 00000000..b298479b --- /dev/null +++ b/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/KafkaClusterHelper.java @@ -0,0 +1,27 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. + * Licensed 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 com.amazonaws.services.schemaregistry.integrationtests.schema_replication; + +public interface KafkaClusterHelper { + String getOrCreateCluster(); + + String getSrcClusterBootstrapString(); + + String getDestClusterBootstrapString(); + + int getNumberOfPartitions(); + + short getReplicationFactor(); +} \ No newline at end of file diff --git a/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/KafkaHelper.java b/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/KafkaHelper.java new file mode 100644 index 00000000..5dd3358a --- /dev/null +++ b/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/KafkaHelper.java @@ -0,0 +1,343 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. + * Licensed 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 com.amazonaws.services.schemaregistry.integrationtests.schema_replication; + +import com.amazonaws.services.schemaregistry.deserializers.GlueSchemaRegistryKafkaDeserializer; +import com.amazonaws.services.schemaregistry.integrationtests.generators.AvroGenericBackwardCompatDataGenerator; +import com.amazonaws.services.schemaregistry.integrationtests.generators.JsonSchemaGenericBackwardCompatDataGenerator; +import com.amazonaws.services.schemaregistry.integrationtests.generators.ProtobufGenericBackwardDataGenerator; +import com.amazonaws.services.schemaregistry.kafkastreams.GlueSchemaRegistryKafkaStreamsSerde; +import com.amazonaws.services.schemaregistry.serializers.GlueSchemaRegistryKafkaSerializer; +import com.amazonaws.services.schemaregistry.serializers.json.JsonDataWithSchema; +import com.amazonaws.services.schemaregistry.utils.AWSSchemaRegistryConstants; +import com.google.protobuf.Message; +import lombok.extern.slf4j.Slf4j; +import org.apache.avro.generic.GenericRecord; +import org.apache.kafka.clients.admin.AdminClient; +import org.apache.kafka.clients.admin.CreateTopicsResult; +import org.apache.kafka.clients.admin.NewTopic; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.clients.consumer.ConsumerRecords; +import org.apache.kafka.clients.consumer.KafkaConsumer; +import org.apache.kafka.clients.producer.KafkaProducer; +import org.apache.kafka.clients.producer.Producer; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.common.serialization.Serdes; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.apache.kafka.common.serialization.StringSerializer; +import org.apache.kafka.streams.KafkaStreams; +import org.apache.kafka.streams.StreamsBuilder; +import org.apache.kafka.streams.StreamsConfig; +import org.apache.kafka.streams.errors.StreamsException; +import org.apache.kafka.streams.kstream.KStream; +import software.amazon.awssdk.services.glue.model.DataFormat; + +import java.time.Duration; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +@Slf4j +public class KafkaHelper { + private static final Duration CONSUMER_RUNTIME = Duration.ofMillis(10000); + private final String bootstrapBrokers; + private final String clusterArn; + + public KafkaHelper(final String bootstrapString, final String clusterArn) { + this.bootstrapBrokers = bootstrapString; + this.clusterArn = clusterArn; + } + + /** + * Helper function to create test topic + * + * @param topic topic name to be created + * @param numPartitions number of numPartitions + * @param replicationFactor replicationFactor count + * @throws Exception + */ + public void createTopic(final String topic, final int numPartitions, final short replicationFactor) throws Exception { + final Properties properties = new Properties(); + properties.put("bootstrap.servers", bootstrapBrokers); + properties.put("client.id", "gsr-integration-tests"); + + log.info("Creating Kafka topic {} with bootstrap {}...", topic, bootstrapBrokers); + try (AdminClient kafkaAdminClient = AdminClient.create(properties)) { + final NewTopic newTopic = new NewTopic(topic, numPartitions, replicationFactor); + final CreateTopicsResult createTopicsResult = kafkaAdminClient + .createTopics(Collections.singleton(newTopic)); + createTopicsResult.values().get(topic).get(); + } catch (Exception e) { + e.printStackTrace(); + throw e; + } + } + + /** + * Helper function to test producer can send messages + * + * @param topic topic to send messages to + * @param numRecords number of records to be sent + * @throws Exception + */ + public void doProduce(final String topic, final int numRecords) throws Exception { + log.info("Start producing to cluster {} with bootstrap {}...", clusterArn, bootstrapBrokers); + + final Properties properties = getKafkaProducerProperties(); + properties.put("key.serializer", StringSerializer.class.getName()); + properties.put("value.serializer", StringSerializer.class.getName()); + + try (Producer producer = new KafkaProducer<>(properties)) { + for (int i = 0; i < numRecords; i++) { + log.info("Producing record " + i); + producer.send(new ProducerRecord<>(topic, Integer.toString(i), Integer.toString(i))).get(); + } + } + + log.info("Finishing producing messages via Kafka."); + } + + /** + * Helper method to test consumption of records + * + * @param consumerProperties consumerProperties + * @return + */ + public int doConsume(final ConsumerProperties consumerProperties) { + final Properties properties = getKafkaConsumerProperties(consumerProperties); + properties.put("key.deserializer", StringDeserializer.class.getName()); + properties.put("value.deserializer", StringDeserializer.class.getName()); + final KafkaConsumer consumer = new KafkaConsumer<>(properties); + return consumeRecords(consumer, consumerProperties.getTopicName()).size(); + } + + /** + * Helper function to produce test AVRO records for Streams + * + * @param producerProperties producerProperties + * @throws Exception + */ + public List> doProduceAvroRecordsSerde(final ProducerProperties producerProperties, + final List records) throws Exception { + Properties properties = getProducerProperties(producerProperties); + final Producer producer = + new KafkaProducer<>(properties, new StringSerializer(), + new GlueSchemaRegistryKafkaSerializer(getMapFromPropertiesFile(properties))); + return produceRecords(producer, producerProperties, records); + } + + /** + * Helper function to consume test AVRO records for Streams + * + * @param consumerProperties consumerProperties + * @return + */ + public List> doConsumeAvroRecordsSerde(final ConsumerProperties consumerProperties) { + Properties properties = getConsumerProperties(consumerProperties); + KafkaConsumer consumer = new KafkaConsumer<>(properties, new StringDeserializer(), + new GlueSchemaRegistryKafkaDeserializer(getMapFromPropertiesFile(properties))); + return consumeRecords(consumer, consumerProperties.getTopicName()); + } + + /** + * Helper function to produce test AVRO records + * + * @param producerProperties producer properties + * @return list of produced records + */ + public List> doProduceRecords(final ProducerProperties producerProperties, + final List records) throws Exception { + Properties properties = getProducerProperties(producerProperties); + properties.put("key.serializer", StringSerializer.class.getName()); + properties.put("value.serializer", GlueSchemaRegistryKafkaSerializer.class.getName()); + Producer producer = new KafkaProducer<>(properties); + + return produceRecords(producer, producerProperties, records); + } + + /** + * Helper function to test consumption of records + * + * @param + */ + public List> doConsumeRecords(final ConsumerProperties consumerProperties) { + Properties properties = getConsumerProperties(consumerProperties); + properties.put("key.deserializer", StringDeserializer.class.getName()); + properties.put("value.deserializer", GlueSchemaRegistryKafkaDeserializer.class.getName()); + + properties.forEach((k, v) -> System.out.println(k + ":" + v)); + final KafkaConsumer consumer = new KafkaConsumer<>(properties); + return consumeRecords(consumer, consumerProperties.getTopicName()); + } + + /** + * Helper function to process Kafka Streams + * + * @param producerProperties + */ + + private List> produceRecords(final Producer producer, + final ProducerProperties producerProperties, + final List records) throws Exception { + log.info("Start producing to cluster {} with bootstrap {}...", clusterArn, bootstrapBrokers); + List> producerRecords = new ArrayList<>(); + + for (int i = 0; i < records.size(); i++) { + log.info("Fetching record {} for Kafka: {}", i, (T) records.get(i)); + + final ProducerRecord producerRecord; + + // Verify and use a unique field present in the schema as a key for the producer record. + producerRecord = new ProducerRecord<>(producerProperties.getTopicName(), "message-" + i, (T) records.get(i)); + + producerRecords.add(producerRecord); + producer.send(producerRecord); + Thread.sleep(500); + log.info("Sent {} message {}", producerProperties.getDataFormat(), i); + } + producer.flush(); + log.info("Successfully produced {} messages to a topic called {}", records.size(), producerProperties.getTopicName()); + return producerRecords; + } + + private List> consumeRecords(final KafkaConsumer consumer, + final String topic) { + log.info("Start consuming from cluster {} with bootstrap {} ...", clusterArn, bootstrapBrokers); + + consumer.subscribe(Collections.singleton(topic)); + System.out.println("======================================================="); + System.out.println("======================================================="); + System.out.println("TOPICS TO READ FROM: " + topic); + System.out.println("======================================================="); + System.out.println("======================================================="); + List> consumerRecords = new ArrayList<>(); + final long now = System.currentTimeMillis(); + while (System.currentTimeMillis() - now < CONSUMER_RUNTIME.toMillis()) { + final ConsumerRecords recordsReceived = consumer.poll(Duration.ofMillis(CONSUMER_RUNTIME.toMillis())); + int i = 0; + for (final ConsumerRecord record : recordsReceived) { + final String key = record.key(); + final T value = record.value(); + log.info("Received message {}: key = {}, value = {}", i, key, value); + consumerRecords.add(record); + i++; + } + } + + consumer.close(); + log.info("Finished consuming messages via Kafka."); + return consumerRecords; + } + + /** + * Helper function to produce test AVRO records in multithreaded manner + * + * @param producerProperties producerProperties + * @return + */ + public List> doProduceRecordsMultithreaded(final ProducerProperties producerProperties, + final List records) throws Exception { + Properties properties = getProducerProperties(producerProperties); + properties.put("key.serializer", StringSerializer.class.getName()); + properties.put("value.serializer", GlueSchemaRegistryKafkaSerializer.class.getName()); + + int numberOfThreads = 4; + List> futures = new ArrayList<>(); + List> producerRecords = new ArrayList<>(); + + for (int i = 0; i < numberOfThreads; i++) { + futures.add(CompletableFuture.runAsync(() -> { + Producer producer = new KafkaProducer<>(properties); + try { + producerRecords.addAll(produceRecords(producer, producerProperties, records)); + } catch (Exception e) { + throw new CompletionException(e); + } + })); + } + + CompletableFuture future = + CompletableFuture.allOf(futures.toArray(new CompletableFuture[futures.size()])); + + future.get(); + return producerRecords; + } + + private Properties getProducerProperties(final ProducerProperties producerProperties) { + Properties properties = getKafkaProducerProperties(); + setSchemaRegistrySerializerProperties(properties, producerProperties); + return properties; + } + + private Properties getKafkaProducerProperties() { + Properties properties = new Properties(); + properties.put("bootstrap.servers", bootstrapBrokers); + properties.put("acks", "all"); + properties.put("retries", 0); + properties.put("batch.size", 16384); + properties.put("linger.ms", 1); + properties.put("buffer.memory", 33554432); + properties.put("block.on.buffer.full", false); + properties.put("request.timeout.ms", "1000"); + return properties; + } + + private Properties getConsumerProperties(final ConsumerProperties consumerProperties) { + Properties properties = getKafkaConsumerProperties(consumerProperties); + return properties; + } + + private Properties getKafkaConsumerProperties(final ConsumerProperties consumerProperties) { + Properties properties = new Properties(); + properties.put("bootstrap.servers", bootstrapBrokers); + properties.put("group.id", UUID.randomUUID().toString()); + properties.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); + properties.put(AWSSchemaRegistryConstants.AWS_ENDPOINT, consumerProperties.DEST_ENDPOINT); + properties.put(AWSSchemaRegistryConstants.AWS_REGION, consumerProperties.DEST_REGION); + + if(consumerProperties.getAvroRecordType() != null) { + properties.put(AWSSchemaRegistryConstants.AVRO_RECORD_TYPE, consumerProperties.getAvroRecordType()); + } + if(consumerProperties.getProtobufMessageType() != null) { + properties.put(AWSSchemaRegistryConstants.PROTOBUF_MESSAGE_TYPE, + consumerProperties.getProtobufMessageType()); + } + return properties; + } + + private void setSchemaRegistrySerializerProperties(final Properties properties, + final ProducerProperties producerProperties) { + properties.put(AWSSchemaRegistryConstants.AWS_ENDPOINT, producerProperties.SRC_ENDPOINT); + properties.put(AWSSchemaRegistryConstants.AWS_REGION, producerProperties.SRC_REGION); + properties.put(AWSSchemaRegistryConstants.SCHEMA_NAME, producerProperties.getSchemaName()); + properties.put(AWSSchemaRegistryConstants.DATA_FORMAT, producerProperties.getDataFormat()); + properties.put(AWSSchemaRegistryConstants.COMPATIBILITY_SETTING, producerProperties.getCompatibilityType()); + properties.put(AWSSchemaRegistryConstants.SCHEMA_AUTO_REGISTRATION_SETTING, producerProperties.getAutoRegistrationEnabled()); + } + + /** + * Create Config map from the properties Object passed. + * + * @param properties properties of configuration elements. + * @return map of configs. + */ + private Map getMapFromPropertiesFile(Properties properties) { + return new HashMap<>(properties.entrySet().stream() + .collect(Collectors.toMap(e -> e.getKey().toString(), e -> e.getValue()))); + } +} \ No newline at end of file diff --git a/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/LocalKafkaClusterHelper.java b/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/LocalKafkaClusterHelper.java new file mode 100644 index 00000000..6591ae19 --- /dev/null +++ b/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/LocalKafkaClusterHelper.java @@ -0,0 +1,49 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. + * Licensed 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 com.amazonaws.services.schemaregistry.integrationtests.schema_replication; + +public class LocalKafkaClusterHelper implements KafkaClusterHelper { + private static final String FAKE_CLUSTER_ARN = "FAKE_CLUSTER_ARN"; + private static final String SRC_BOOTSTRAP_STRING = "127.0.0.1:9092"; + private static final String DEST_BOOTSTRAP_STRING = "127.0.0.1:9093"; + private static final int NUMBER_OF_PARTITIONS = 1; + private static final short REPLICATION_FACTOR = 1; + + @Override + public String getOrCreateCluster() { + return FAKE_CLUSTER_ARN; + } + + @Override + public String getSrcClusterBootstrapString() { + return SRC_BOOTSTRAP_STRING; + } + + @Override + public String getDestClusterBootstrapString() { + return DEST_BOOTSTRAP_STRING; + } + + @Override + public int getNumberOfPartitions() { + return NUMBER_OF_PARTITIONS; + } + + @Override + public short getReplicationFactor() { + return REPLICATION_FACTOR; + } +} + diff --git a/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/ProducerProperties.java b/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/ProducerProperties.java new file mode 100644 index 00000000..93724606 --- /dev/null +++ b/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/ProducerProperties.java @@ -0,0 +1,34 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. + * Licensed 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 com.amazonaws.services.schemaregistry.integrationtests.schema_replication; + +import com.amazonaws.services.schemaregistry.integrationtests.properties.GlueSchemaRegistryConnectionProperties; +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +public class ProducerProperties implements GlueSchemaRegistryConnectionProperties { + private String topicName; + private String schemaName; + private String dataFormat; + private String compatibilityType; + private String autoRegistrationEnabled; + // Streaming properties + private String inputTopic; + private String outputTopic; + private String recordType; // required only for AVRO or Protobuf case +} + From 3bd91a0512de180380f5e7ed894f1af7578b73dc Mon Sep 17 00:00:00 2001 From: Subham Rakshit Date: Tue, 18 Jun 2024 20:50:22 +0100 Subject: [PATCH 19/41] loading aws credentials from host to docker --- integration-tests/docker-compose.yml | 1 + integration-tests/mirrormaker/Dockerfile | 16 ++++++++++++++++ .../schema_replication/KafkaHelper.java | 5 ----- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/integration-tests/docker-compose.yml b/integration-tests/docker-compose.yml index ea31f96c..de8a2552 100644 --- a/integration-tests/docker-compose.yml +++ b/integration-tests/docker-compose.yml @@ -52,6 +52,7 @@ services: - ACLS_ENABLED=false volumes: - glue-schema-registry-plugins:/opt/plugins + - "${HOME}/.aws/:/.aws:ro" depends_on: - kafka - kafka-target diff --git a/integration-tests/mirrormaker/Dockerfile b/integration-tests/mirrormaker/Dockerfile index 6b3441bf..35fc042f 100644 --- a/integration-tests/mirrormaker/Dockerfile +++ b/integration-tests/mirrormaker/Dockerfile @@ -5,6 +5,19 @@ RUN install_packages gettext RUN mkdir -p /opt/plugins RUN chown 1234 /opt/plugins +RUN mkdir -p ~/.aws +RUN chmod 1234 ~/.aws + +# Install the AWS CLI +RUN \ + apt-get update -y && \ + apt-get install -y wget vim python3 unzip python-is-python3 python3-venv && \ + wget "s3.amazonaws.com/aws-cli/awscli-bundle.zip" -O "awscli-bundle.zip" && \ + unzip awscli-bundle.zip && \ + ./awscli-bundle/install -i /usr/local/aws -b /usr/local/bin/aws && \ + rm awscli-bundle.zip && \ + rm -rf awscli-bundle + ADD ./mm2-configs/connect-standalone.properties /opt/mm2/connect-standalone.properties ADD ./mm2-configs/mirror-checkpoint-connector.properties /opt/mm2/mirror-checkpoint-connector.properties ADD ./mm2-configs/mirror-heartbeat-connector.properties /opt/mm2/mirror-heartbeat-connector.properties @@ -20,6 +33,9 @@ ENV TOPICS .* ENV SOURCE "localhost:9092" ENV DESTINATION "localhost:9093" ENV ACLS_ENABLED "false" +ENV AWS_ACCESS_KEY_ID "" +ENV AWS_SECRET_ACCESS_KEY "" +ENV AWS_SESSION_TOKEN "" USER 1234 CMD /opt/mm2/run.sh diff --git a/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/KafkaHelper.java b/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/KafkaHelper.java index 5dd3358a..54a924a7 100644 --- a/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/KafkaHelper.java +++ b/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/KafkaHelper.java @@ -220,11 +220,6 @@ private List> consumeRecords(final KafkaConsumer> consumerRecords = new ArrayList<>(); final long now = System.currentTimeMillis(); while (System.currentTimeMillis() - now < CONSUMER_RUNTIME.toMillis()) { From 569d4b366158c387d9c3ac7a4f24500d1cd09b2e Mon Sep 17 00:00:00 2001 From: Subham Rakshit Date: Tue, 18 Jun 2024 22:37:08 +0100 Subject: [PATCH 20/41] Configuring default aws profile --- integration-tests/docker-compose.yml | 5 +++-- integration-tests/mirrormaker/Dockerfile | 4 +--- integration-tests/run-local-tests.sh | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) mode change 100644 => 100755 integration-tests/run-local-tests.sh diff --git a/integration-tests/docker-compose.yml b/integration-tests/docker-compose.yml index de8a2552..ea1ea579 100644 --- a/integration-tests/docker-compose.yml +++ b/integration-tests/docker-compose.yml @@ -30,7 +30,7 @@ services: image: 'public.ecr.aws/bitnami/kafka:2.8' ports: - '9093:9093' - container_name: kafka_target + container_name: kafka-target environment: - KAFKA_BROKER_ID=2 - KAFKA_CFG_ZOOKEEPER_CONNECT=zookeeper-kafka-target:2181 @@ -43,13 +43,14 @@ services: mirrormaker: build: ./mirrormaker - container_name: mm2 + container_name: mirrormaker environment: - ALLOW_PLAINTEXT_LISTENER=yes - SOURCE=kafka:29092 - DESTINATION=kafka-target:29092 - TOPICS=^SchemaRegistryTests.* - ACLS_ENABLED=false + - AWS_PROFILE=default volumes: - glue-schema-registry-plugins:/opt/plugins - "${HOME}/.aws/:/.aws:ro" diff --git a/integration-tests/mirrormaker/Dockerfile b/integration-tests/mirrormaker/Dockerfile index 35fc042f..ad44e212 100644 --- a/integration-tests/mirrormaker/Dockerfile +++ b/integration-tests/mirrormaker/Dockerfile @@ -33,9 +33,7 @@ ENV TOPICS .* ENV SOURCE "localhost:9092" ENV DESTINATION "localhost:9093" ENV ACLS_ENABLED "false" -ENV AWS_ACCESS_KEY_ID "" -ENV AWS_SECRET_ACCESS_KEY "" -ENV AWS_SESSION_TOKEN "" +ENV AWS_PROFILE "default" USER 1234 CMD /opt/mm2/run.sh diff --git a/integration-tests/run-local-tests.sh b/integration-tests/run-local-tests.sh old mode 100644 new mode 100755 index 66c797cf..a8a43f77 --- a/integration-tests/run-local-tests.sh +++ b/integration-tests/run-local-tests.sh @@ -117,7 +117,7 @@ cleanUpDockerResources || true # Start Kafka using docker command asynchronously docker-compose up --no-attach localstack & sleep 10 -## Run mvn tests for Kafka and Kinesis Platforms +## Run mvn tests for Kafka, Kinesis Platforms and Schema Replication cd .. && mvn --file integration-tests/pom.xml verify -Psurefire -X && cd integration-tests cleanUpDockerResources From 6f2bcc498189bae27aede2d59aa463a399f4b5c9 Mon Sep 17 00:00:00 2001 From: Subham Rakshit Date: Wed, 3 Jul 2024 22:40:58 +0100 Subject: [PATCH 21/41] Renamed class to AWSGlueCrossRegionSchemaReplicationConverter --- .../utils/AWSSchemaRegistryConstants.java | 4 ++-- ...eCrossRegionSchemaReplicationConverter.java} | 6 +++--- ...ssRegionSchemaReplicationConverterTest.java} | 3 +-- .../mirror-source-connector.properties | 6 +++--- ...RegionSchemaReplicationIntegrationTest.java} | 11 +++-------- .../ConsumerProperties.java | 2 +- .../KafkaClusterHelper.java | 2 +- .../KafkaHelper.java | 17 +---------------- .../LocalKafkaClusterHelper.java | 2 +- .../ProducerProperties.java | 2 +- 10 files changed, 17 insertions(+), 38 deletions(-) rename cross-region-replication-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/{CrossRegionReplicationConverter.java => AWSGlueCrossRegionSchemaReplicationConverter.java} (96%) rename cross-region-replication-converter/src/test/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/{CrossRegionReplicationConverterTest.java => AWSGlueCrossRegionSchemaReplicationConverterTest.java} (99%) rename integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/{schema_replication/GlueSchemaRegistrySchemaReplicationIntegrationTest.java => schemareplication/AWSGlueCrossRegionSchemaReplicationIntegrationTest.java} (96%) rename integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/{schema_replication => schemareplication}/ConsumerProperties.java (98%) rename integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/{schema_replication => schemareplication}/KafkaClusterHelper.java (97%) rename integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/{schema_replication => schemareplication}/KafkaHelper.java (93%) rename integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/{schema_replication => schemareplication}/LocalKafkaClusterHelper.java (98%) rename integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/{schema_replication => schemareplication}/ProducerProperties.java (98%) diff --git a/common/src/main/java/com/amazonaws/services/schemaregistry/utils/AWSSchemaRegistryConstants.java b/common/src/main/java/com/amazonaws/services/schemaregistry/utils/AWSSchemaRegistryConstants.java index 856f3b91..cf20f0a0 100644 --- a/common/src/main/java/com/amazonaws/services/schemaregistry/utils/AWSSchemaRegistryConstants.java +++ b/common/src/main/java/com/amazonaws/services/schemaregistry/utils/AWSSchemaRegistryConstants.java @@ -31,11 +31,11 @@ public final class AWSSchemaRegistryConstants { */ public static final String AWS_REGION = "region"; /** - * TODO: CR_GSR: AWS source endpoint to use while initializing the client for service. + * AWS source endpoint to use while initializing the client for service. */ public static final String AWS_SOURCE_ENDPOINT = "source.endpoint"; /** - * TODO: CR_GSR: AWS source region to use while initializing the client for service. + * AWS source region to use while initializing the client for service. */ public static final String AWS_SOURCE_REGION = "source.region"; /** diff --git a/cross-region-replication-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverter.java b/cross-region-replication-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/AWSGlueCrossRegionSchemaReplicationConverter.java similarity index 96% rename from cross-region-replication-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverter.java rename to cross-region-replication-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/AWSGlueCrossRegionSchemaReplicationConverter.java index e49b654b..a45563a1 100644 --- a/cross-region-replication-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverter.java +++ b/cross-region-replication-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/AWSGlueCrossRegionSchemaReplicationConverter.java @@ -18,7 +18,7 @@ import java.util.Map; @Data -public class CrossRegionReplicationConverter implements Converter { +public class AWSGlueCrossRegionSchemaReplicationConverter implements Converter { private AwsCredentialsProvider credentialsProvider; private GlueSchemaRegistryDeserializerImpl deserializer; @@ -28,14 +28,14 @@ public class CrossRegionReplicationConverter implements Converter { /** * Constructor used by Kafka Connect user. */ - public CrossRegionReplicationConverter(){}; + public AWSGlueCrossRegionSchemaReplicationConverter(){}; /** * Constructor accepting AWSCredentialsProvider. * * @param credentialsProvider AWSCredentialsProvider instance. */ - public CrossRegionReplicationConverter( + public AWSGlueCrossRegionSchemaReplicationConverter( AwsCredentialsProvider credentialsProvider, GlueSchemaRegistryDeserializerImpl deserializerImpl, GlueSchemaRegistrySerializerImpl serializerImpl) { diff --git a/cross-region-replication-converter/src/test/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverterTest.java b/cross-region-replication-converter/src/test/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/AWSGlueCrossRegionSchemaReplicationConverterTest.java similarity index 99% rename from cross-region-replication-converter/src/test/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverterTest.java rename to cross-region-replication-converter/src/test/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/AWSGlueCrossRegionSchemaReplicationConverterTest.java index bf917d56..206c5598 100644 --- a/cross-region-replication-converter/src/test/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/CrossRegionReplicationConverterTest.java +++ b/cross-region-replication-converter/src/test/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/AWSGlueCrossRegionSchemaReplicationConverterTest.java @@ -22,7 +22,6 @@ import software.amazon.awssdk.services.glue.model.DataFormat; import java.nio.charset.StandardCharsets; -import java.util.Arrays; import java.util.HashMap; import java.util.Map; @@ -35,7 +34,7 @@ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) -public class CrossRegionReplicationConverterTest { +public class AWSGlueCrossRegionSchemaReplicationConverterTest { @Mock private AwsCredentialsProvider credProvider; @Mock diff --git a/integration-tests/mirrormaker/mm2-configs/mirror-source-connector.properties b/integration-tests/mirrormaker/mm2-configs/mirror-source-connector.properties index b77ae55f..863b52eb 100644 --- a/integration-tests/mirrormaker/mm2-configs/mirror-source-connector.properties +++ b/integration-tests/mirrormaker/mm2-configs/mirror-source-connector.properties @@ -6,9 +6,9 @@ target.cluster.alias=dst source.cluster.bootstrap.servers=${SOURCE} target.cluster.bootstrap.servers=${DESTINATION} topics=${TOPICS} -tasks.max=2 -key.converter=com.amazonaws.services.crossregion.schemaregistry.kafkaconnect.CrossRegionReplicationConverter -value.converter=com.amazonaws.services.crossregion.schemaregistry.kafkaconnect.CrossRegionReplicationConverter +tasks.max=1 +key.converter=com.amazonaws.services.crossregion.schemaregistry.kafkaconnect.AWSGlueCrossRegionSchemaReplicationConverter +value.converter=com.amazonaws.services.crossregion.schemaregistry.kafkaconnect.AWSGlueCrossRegionSchemaReplicationConverter replication.factor=1 offset-syncs.topic.replication.factor=1 sync.topic.acls.enabled=${ACLS_ENABLED} diff --git a/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/GlueSchemaRegistrySchemaReplicationIntegrationTest.java b/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schemareplication/AWSGlueCrossRegionSchemaReplicationIntegrationTest.java similarity index 96% rename from integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/GlueSchemaRegistrySchemaReplicationIntegrationTest.java rename to integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schemareplication/AWSGlueCrossRegionSchemaReplicationIntegrationTest.java index bba2541f..b3cbefca 100644 --- a/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/GlueSchemaRegistrySchemaReplicationIntegrationTest.java +++ b/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schemareplication/AWSGlueCrossRegionSchemaReplicationIntegrationTest.java @@ -12,19 +12,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.amazonaws.services.schemaregistry.integrationtests.schema_replication; +package com.amazonaws.services.schemaregistry.integrationtests.schemareplication; -import com.amazonaws.services.schemaregistry.deserializers.protobuf.ProtobufClassName; import com.amazonaws.services.schemaregistry.integrationtests.generators.*; import com.amazonaws.services.schemaregistry.integrationtests.properties.GlueSchemaRegistryConnectionProperties; -import com.amazonaws.services.schemaregistry.serializers.json.JsonDataWithSchema; -import com.amazonaws.services.schemaregistry.utils.AWSSchemaRegistryConstants; import com.amazonaws.services.schemaregistry.utils.AvroRecordType; import com.amazonaws.services.schemaregistry.utils.ProtobufMessageType; import com.google.protobuf.DynamicMessage; import com.google.protobuf.Message; import lombok.extern.slf4j.Slf4j; -import org.apache.avro.generic.GenericRecord; import org.apache.commons.lang3.RandomStringUtils; import org.apache.commons.lang3.tuple.Pair; import org.apache.kafka.clients.consumer.ConsumerRecord; @@ -33,7 +29,6 @@ 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.EnumSource; import org.junit.jupiter.params.provider.MethodSource; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; @@ -62,7 +57,7 @@ * The test class for schema replication related tests for Glue Schema Registry */ @Slf4j -public class GlueSchemaRegistrySchemaReplicationIntegrationTest { +public class AWSGlueCrossRegionSchemaReplicationIntegrationTest { private static final String SRC_CLUSTER_ALIAS = "src"; private static final String TOPIC_NAME_PREFIX = "SchemaRegistryTests"; private static final String SCHEMA_REGISTRY_SRC_ENDPOINT_OVERRIDE = GlueSchemaRegistryConnectionProperties.SRC_ENDPOINT; @@ -110,7 +105,7 @@ public void testProduceConsumeWithoutGlueSchemaRegistry() throws Exception { //Delay added to allow MM2 copy the data to destination cluster //before consuming the records from the destination cluster - Thread.sleep(5000); + Thread.sleep(60000); ConsumerProperties consumerProperties = ConsumerProperties.builder() .topicName(String.format("%s.%s",SRC_CLUSTER_ALIAS, topic)) diff --git a/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/ConsumerProperties.java b/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schemareplication/ConsumerProperties.java similarity index 98% rename from integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/ConsumerProperties.java rename to integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schemareplication/ConsumerProperties.java index 870325a3..84b005af 100644 --- a/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/ConsumerProperties.java +++ b/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schemareplication/ConsumerProperties.java @@ -12,7 +12,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.amazonaws.services.schemaregistry.integrationtests.schema_replication; +package com.amazonaws.services.schemaregistry.integrationtests.schemareplication; import com.amazonaws.services.schemaregistry.integrationtests.properties.GlueSchemaRegistryConnectionProperties; import lombok.Builder; diff --git a/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/KafkaClusterHelper.java b/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schemareplication/KafkaClusterHelper.java similarity index 97% rename from integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/KafkaClusterHelper.java rename to integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schemareplication/KafkaClusterHelper.java index b298479b..70409a40 100644 --- a/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/KafkaClusterHelper.java +++ b/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schemareplication/KafkaClusterHelper.java @@ -12,7 +12,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.amazonaws.services.schemaregistry.integrationtests.schema_replication; +package com.amazonaws.services.schemaregistry.integrationtests.schemareplication; public interface KafkaClusterHelper { String getOrCreateCluster(); diff --git a/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/KafkaHelper.java b/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schemareplication/KafkaHelper.java similarity index 93% rename from integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/KafkaHelper.java rename to integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schemareplication/KafkaHelper.java index 54a924a7..64a89c55 100644 --- a/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/KafkaHelper.java +++ b/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schemareplication/KafkaHelper.java @@ -12,19 +12,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.amazonaws.services.schemaregistry.integrationtests.schema_replication; +package com.amazonaws.services.schemaregistry.integrationtests.schemareplication; import com.amazonaws.services.schemaregistry.deserializers.GlueSchemaRegistryKafkaDeserializer; -import com.amazonaws.services.schemaregistry.integrationtests.generators.AvroGenericBackwardCompatDataGenerator; -import com.amazonaws.services.schemaregistry.integrationtests.generators.JsonSchemaGenericBackwardCompatDataGenerator; -import com.amazonaws.services.schemaregistry.integrationtests.generators.ProtobufGenericBackwardDataGenerator; -import com.amazonaws.services.schemaregistry.kafkastreams.GlueSchemaRegistryKafkaStreamsSerde; import com.amazonaws.services.schemaregistry.serializers.GlueSchemaRegistryKafkaSerializer; -import com.amazonaws.services.schemaregistry.serializers.json.JsonDataWithSchema; import com.amazonaws.services.schemaregistry.utils.AWSSchemaRegistryConstants; -import com.google.protobuf.Message; import lombok.extern.slf4j.Slf4j; -import org.apache.avro.generic.GenericRecord; import org.apache.kafka.clients.admin.AdminClient; import org.apache.kafka.clients.admin.CreateTopicsResult; import org.apache.kafka.clients.admin.NewTopic; @@ -35,21 +28,13 @@ import org.apache.kafka.clients.producer.KafkaProducer; import org.apache.kafka.clients.producer.Producer; import org.apache.kafka.clients.producer.ProducerRecord; -import org.apache.kafka.common.serialization.Serdes; import org.apache.kafka.common.serialization.StringDeserializer; import org.apache.kafka.common.serialization.StringSerializer; -import org.apache.kafka.streams.KafkaStreams; -import org.apache.kafka.streams.StreamsBuilder; -import org.apache.kafka.streams.StreamsConfig; -import org.apache.kafka.streams.errors.StreamsException; -import org.apache.kafka.streams.kstream.KStream; -import software.amazon.awssdk.services.glue.model.DataFormat; import java.time.Duration; import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; -import java.util.regex.Pattern; import java.util.stream.Collectors; @Slf4j diff --git a/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/LocalKafkaClusterHelper.java b/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schemareplication/LocalKafkaClusterHelper.java similarity index 98% rename from integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/LocalKafkaClusterHelper.java rename to integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schemareplication/LocalKafkaClusterHelper.java index 6591ae19..48610bca 100644 --- a/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/LocalKafkaClusterHelper.java +++ b/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schemareplication/LocalKafkaClusterHelper.java @@ -12,7 +12,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.amazonaws.services.schemaregistry.integrationtests.schema_replication; +package com.amazonaws.services.schemaregistry.integrationtests.schemareplication; public class LocalKafkaClusterHelper implements KafkaClusterHelper { private static final String FAKE_CLUSTER_ARN = "FAKE_CLUSTER_ARN"; diff --git a/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/ProducerProperties.java b/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schemareplication/ProducerProperties.java similarity index 98% rename from integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/ProducerProperties.java rename to integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schemareplication/ProducerProperties.java index 93724606..0693cc42 100644 --- a/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schema_replication/ProducerProperties.java +++ b/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schemareplication/ProducerProperties.java @@ -12,7 +12,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.amazonaws.services.schemaregistry.integrationtests.schema_replication; +package com.amazonaws.services.schemaregistry.integrationtests.schemareplication; import com.amazonaws.services.schemaregistry.integrationtests.properties.GlueSchemaRegistryConnectionProperties; import lombok.Builder; From 8c84686657aa102817795e527f2d3a74d0c93bd3 Mon Sep 17 00:00:00 2001 From: Subham Rakshit Date: Wed, 3 Jul 2024 23:52:36 +0100 Subject: [PATCH 22/41] Increased the sleep time --- .../AWSGlueCrossRegionSchemaReplicationIntegrationTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schemareplication/AWSGlueCrossRegionSchemaReplicationIntegrationTest.java b/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schemareplication/AWSGlueCrossRegionSchemaReplicationIntegrationTest.java index b3cbefca..8d66a7d1 100644 --- a/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schemareplication/AWSGlueCrossRegionSchemaReplicationIntegrationTest.java +++ b/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schemareplication/AWSGlueCrossRegionSchemaReplicationIntegrationTest.java @@ -105,7 +105,7 @@ public void testProduceConsumeWithoutGlueSchemaRegistry() throws Exception { //Delay added to allow MM2 copy the data to destination cluster //before consuming the records from the destination cluster - Thread.sleep(60000); + Thread.sleep(8000); ConsumerProperties consumerProperties = ConsumerProperties.builder() .topicName(String.format("%s.%s",SRC_CLUSTER_ALIAS, topic)) @@ -149,7 +149,7 @@ public void testProduceConsumeWithSchemaRegistryForAllThreeDataFormats(final Dat //Delay added to allow MM2 copy the data to destination cluster //before consuming the records from the destination cluster - Thread.sleep(3000); + Thread.sleep(8000); ConsumerProperties.ConsumerPropertiesBuilder consumerPropertiesBuilder = ConsumerProperties.builder() .topicName(String.format("%s.%s",SRC_CLUSTER_ALIAS, topic)); From 110b79216f6107d81c136d828a0fa335005c9311 Mon Sep 17 00:00:00 2001 From: Subham Rakshit Date: Mon, 8 Jul 2024 22:55:18 +0100 Subject: [PATCH 23/41] Source schema compatibility mode is persisted in target schema --- .../common/AWSSchemaRegistryClient.java | 103 ++++++++++++++---- .../common/AWSSerializerInput.java | 12 +- .../common/SchemaByDefinitionFetcher.java | 84 ++++++++++++++ .../schemaregistry/common/SchemaV2.java | 47 ++++++++ ...CrossRegionSchemaReplicationConverter.java | 6 +- .../mirror-source-connector.properties | 2 +- ...egionSchemaReplicationIntegrationTest.java | 17 ++- ...ueSchemaRegistryDeserializationFacade.java | 85 +++++++++++++++ .../GlueSchemaRegistryDeserializer.java | 10 ++ .../GlueSchemaRegistryDeserializerImpl.java | 11 ++ ...GlueSchemaRegistrySerializationFacade.java | 52 ++++++++- .../GlueSchemaRegistrySerializer.java | 10 ++ .../GlueSchemaRegistrySerializerImpl.java | 24 ++++ 13 files changed, 430 insertions(+), 33 deletions(-) create mode 100644 common/src/main/java/com/amazonaws/services/schemaregistry/common/SchemaV2.java diff --git a/common/src/main/java/com/amazonaws/services/schemaregistry/common/AWSSchemaRegistryClient.java b/common/src/main/java/com/amazonaws/services/schemaregistry/common/AWSSchemaRegistryClient.java index 949e6563..159c5b8b 100644 --- a/common/src/main/java/com/amazonaws/services/schemaregistry/common/AWSSchemaRegistryClient.java +++ b/common/src/main/java/com/amazonaws/services/schemaregistry/common/AWSSchemaRegistryClient.java @@ -36,26 +36,7 @@ import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.glue.GlueClient; import software.amazon.awssdk.services.glue.GlueClientBuilder; -import software.amazon.awssdk.services.glue.model.AlreadyExistsException; -import software.amazon.awssdk.services.glue.model.CreateSchemaRequest; -import software.amazon.awssdk.services.glue.model.CreateSchemaResponse; -import software.amazon.awssdk.services.glue.model.DataFormat; -import software.amazon.awssdk.services.glue.model.GetSchemaByDefinitionRequest; -import software.amazon.awssdk.services.glue.model.GetSchemaByDefinitionResponse; -import software.amazon.awssdk.services.glue.model.GetSchemaVersionRequest; -import software.amazon.awssdk.services.glue.model.GetSchemaVersionResponse; -import software.amazon.awssdk.services.glue.model.GetTagsRequest; -import software.amazon.awssdk.services.glue.model.GetTagsResponse; -import software.amazon.awssdk.services.glue.model.GlueRequest; -import software.amazon.awssdk.services.glue.model.MetadataKeyValuePair; -import software.amazon.awssdk.services.glue.model.PutSchemaVersionMetadataRequest; -import software.amazon.awssdk.services.glue.model.PutSchemaVersionMetadataResponse; -import software.amazon.awssdk.services.glue.model.QuerySchemaVersionMetadataRequest; -import software.amazon.awssdk.services.glue.model.QuerySchemaVersionMetadataResponse; -import software.amazon.awssdk.services.glue.model.RegisterSchemaVersionRequest; -import software.amazon.awssdk.services.glue.model.RegisterSchemaVersionResponse; -import software.amazon.awssdk.services.glue.model.RegistryId; -import software.amazon.awssdk.services.glue.model.SchemaId; +import software.amazon.awssdk.services.glue.model.*; import java.net.URI; import java.net.URISyntaxException; @@ -193,6 +174,35 @@ private void validateSchemaVersionResponse(GetSchemaVersionResponse schemaVersio } } + public GetSchemaResponse getSchemaResponse(@NonNull SchemaId schemaId) + throws AWSSchemaRegistryException { + GetSchemaResponse schemaResponse = null; + + try { + schemaResponse = client.getSchema(getSchemaRequest(schemaId)); + validateSchemaResponse(schemaResponse, schemaId); + } catch (Exception e) { + String errorMessage = String.format("Failed to get schema Id = %s", schemaId); + throw new AWSSchemaRegistryException(errorMessage, e); + } + + return schemaResponse; + } + + private GetSchemaRequest getSchemaRequest(SchemaId schemaId) { + GetSchemaRequest getSchemaRequest = GetSchemaRequest.builder() + .schemaId(schemaId) + .build(); + return getSchemaRequest; + } + + private void validateSchemaResponse(GetSchemaResponse schemaResponse, SchemaId schemaId) { + if (schemaResponse == null || schemaResponse.compatibility() == null) { + String message = String.format("Schema is not present for the schema id = %s", schemaId); + throw new AWSSchemaRegistryException(message); + } + } + private UUID returnSchemaVersionIdIfAvailable(GetSchemaByDefinitionResponse response) { if (response.schemaVersionId() != null && response.statusAsString().equals(AWSSchemaRegistryConstants.SchemaVersionStatus.AVAILABLE.toString())) { @@ -266,6 +276,46 @@ public UUID createSchema(String schemaName, return schemaVersionId; } + + + /** + * Create a schema using the Glue client and return the response object + * @param schemaName Schema Name + * @param dataFormat Data Format + * @param schemaDefinition Schema Definition + * @param compatibility Schema Compatibility mode + * @param metadata schema version metadata + * @return CreateSchemaResponse object + * @throws AWSSchemaRegistryException on any error during the schema creation + */ + public UUID createSchemaV2(String schemaName, + String dataFormat, + String schemaDefinition, + Compatibility compatibility, + Map metadata) throws AWSSchemaRegistryException { + UUID schemaVersionId = null; + try { + log.info("Auto Creating schema with schemaName: {} and schemaDefinition : {}", schemaName, + schemaDefinition); + CreateSchemaResponse createSchemaResponse = + client.createSchema(getCreateSchemaRequestObjectV2(schemaName, dataFormat, schemaDefinition, compatibility)); + schemaVersionId = UUID.fromString(createSchemaResponse.schemaVersionId()); + } catch (AlreadyExistsException e) { + log.warn("Schema is already created, this could be caused by multiple producers racing to " + + "auto-create schema."); + schemaVersionId = registerSchemaVersion(schemaDefinition, schemaName, dataFormat, metadata); + } catch (Exception e) { + String errorMessage = String.format( + "Create schema :: Call failed when creating the schema with the schema registry for" + + " schema name = %s", schemaName); + throw new AWSSchemaRegistryException(errorMessage, e); + } + + putSchemaVersionMetadata(schemaVersionId, metadata); + + return schemaVersionId; + } + /** * Register the schema and return schema version Id once it is available. * @param schemaDefinition Schema Definition @@ -342,6 +392,19 @@ private CreateSchemaRequest getCreateSchemaRequestObject(String schemaName, Stri .build(); } + private CreateSchemaRequest getCreateSchemaRequestObjectV2(String schemaName, String dataFormat, String schemaDefinition, Compatibility compatibility) { + return CreateSchemaRequest + .builder() + .dataFormat(DataFormat.valueOf(dataFormat)) + .description(glueSchemaRegistryConfiguration.getDescription()) + .registryId(RegistryId.builder().registryName(glueSchemaRegistryConfiguration.getRegistryName()).build()) + .schemaName(schemaName) + .schemaDefinition(schemaDefinition) + .compatibility(compatibility) + .tags(glueSchemaRegistryConfiguration.getTags()) + .build(); + } + private RegisterSchemaVersionRequest getRegisterSchemaVersionRequest(String schemaDefinition, String schemaName) { return RegisterSchemaVersionRequest .builder() diff --git a/common/src/main/java/com/amazonaws/services/schemaregistry/common/AWSSerializerInput.java b/common/src/main/java/com/amazonaws/services/schemaregistry/common/AWSSerializerInput.java index 046b9c27..b23b27df 100644 --- a/common/src/main/java/com/amazonaws/services/schemaregistry/common/AWSSerializerInput.java +++ b/common/src/main/java/com/amazonaws/services/schemaregistry/common/AWSSerializerInput.java @@ -19,6 +19,7 @@ import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.ToString; +import software.amazon.awssdk.services.glue.model.Compatibility; /** * Encapsulates general inputs for serializer @@ -38,8 +39,11 @@ public class AWSSerializerInput { @Getter private String transportName; + @Getter + private Compatibility compatibility; + @Builder - public AWSSerializerInput(String schemaDefinition, String schemaName, String dataFormat, String transportName) { + public AWSSerializerInput(String schemaDefinition, String schemaName, String dataFormat, String transportName, Compatibility compatibility) { this.schemaDefinition = schemaDefinition; if (transportName != null) { @@ -55,5 +59,11 @@ public AWSSerializerInput(String schemaDefinition, String schemaName, String dat } this.dataFormat = dataFormat; + + if (compatibility != null) { + this.compatibility = compatibility; + } else { + this.compatibility = Compatibility.BACKWARD; + } } } diff --git a/common/src/main/java/com/amazonaws/services/schemaregistry/common/SchemaByDefinitionFetcher.java b/common/src/main/java/com/amazonaws/services/schemaregistry/common/SchemaByDefinitionFetcher.java index dda8d0b4..f2e8f4d8 100644 --- a/common/src/main/java/com/amazonaws/services/schemaregistry/common/SchemaByDefinitionFetcher.java +++ b/common/src/main/java/com/amazonaws/services/schemaregistry/common/SchemaByDefinitionFetcher.java @@ -10,6 +10,7 @@ import lombok.NonNull; import lombok.RequiredArgsConstructor; import lombok.SneakyThrows; +import software.amazon.awssdk.services.glue.model.Compatibility; import java.util.Map; import java.util.UUID; @@ -29,6 +30,10 @@ public class SchemaByDefinitionFetcher { @VisibleForTesting protected final LoadingCache schemaDefinitionToVersionCache; + @NonNull + @VisibleForTesting + protected final LoadingCache schemaDefinitionToVersionCacheV2; + public SchemaByDefinitionFetcher( final AWSSchemaRegistryClient awsSchemaRegistryClient, final GlueSchemaRegistryConfiguration glueSchemaRegistryConfiguration) { @@ -39,6 +44,11 @@ public SchemaByDefinitionFetcher( .maximumSize(glueSchemaRegistryConfiguration.getCacheSize()) .refreshAfterWrite(glueSchemaRegistryConfiguration.getTimeToLiveMillis(), TimeUnit.MILLISECONDS) .build(new SchemaDefinitionToVersionCache()); + + this.schemaDefinitionToVersionCacheV2 = CacheBuilder.newBuilder() + .maximumSize(glueSchemaRegistryConfiguration.getCacheSize()) + .refreshAfterWrite(glueSchemaRegistryConfiguration.getTimeToLiveMillis(), TimeUnit.MILLISECONDS) + .build(new SchemaDefinitionToVersionCacheV2()); } /** @@ -103,6 +113,71 @@ public UUID getORRegisterSchemaVersionId( return schemaVersionId; } + + + /** + * Get Schema Version ID by following below steps : + *

+ * 1) If schema version id exists in registry then get it from registry + * 2) If schema version id does not exist in registry + * then if auto registration is enabled + * then if schema exists but version doesn't exist + * then + * 2.1) Register schema version + * else if schema does not exist + * then + * 2.2) create schema and register schema version + * + * @param schemaDefinition Schema Definition + * @param schemaName Schema Name + * @param dataFormat Data Format + * @param metadata metadata for schema version + * @return Schema Version ID + * @throws AWSSchemaRegistryException on any error while fetching the schema version ID + */ + @SneakyThrows + public UUID getORRegisterSchemaVersionIdV2( + @NonNull String schemaDefinition, + @NonNull String schemaName, + @NonNull String dataFormat, + @NonNull Compatibility compatibility, + @NonNull Map metadata) throws AWSSchemaRegistryException { + UUID schemaVersionId; + final SchemaV2 schema = new SchemaV2(schemaDefinition, dataFormat, schemaName, compatibility); + + try { + return schemaDefinitionToVersionCacheV2.get(schema); + } catch (Exception ex) { + Throwable schemaRegistryException = ex.getCause(); + String exceptionCauseMessage = schemaRegistryException.getCause().getMessage(); + + if (exceptionCauseMessage.contains(AWSSchemaRegistryConstants.SCHEMA_VERSION_NOT_FOUND_MSG)) { + if (!glueSchemaRegistryConfiguration.isSchemaAutoRegistrationEnabled()) { + throw new AWSSchemaRegistryException(AWSSchemaRegistryConstants.AUTO_REGISTRATION_IS_DISABLED_MSG, + schemaRegistryException); + } + schemaVersionId = + awsSchemaRegistryClient.registerSchemaVersion(schemaDefinition, schemaName, dataFormat, metadata); + } else if (exceptionCauseMessage.contains(AWSSchemaRegistryConstants.SCHEMA_NOT_FOUND_MSG)) { + if (!glueSchemaRegistryConfiguration.isSchemaAutoRegistrationEnabled()) { + throw new AWSSchemaRegistryException(AWSSchemaRegistryConstants.AUTO_REGISTRATION_IS_DISABLED_MSG, + schemaRegistryException); + } + + schemaVersionId = + awsSchemaRegistryClient.createSchemaV2(schemaName, dataFormat, schemaDefinition, compatibility, metadata); + } else { + String msg = + String.format( + "Exception occurred while fetching or registering schema definition = %s, schema name = %s ", + schemaDefinition, schemaName); + throw new AWSSchemaRegistryException(msg, schemaRegistryException); + } + schemaDefinitionToVersionCacheV2.put(schema, schemaVersionId); + } + return schemaVersionId; + } + @RequiredArgsConstructor private class SchemaDefinitionToVersionCache extends CacheLoader { @Override @@ -111,4 +186,13 @@ public UUID load(Schema schema) { schema.getSchemaDefinition(), schema.getSchemaName(), schema.getDataFormat()); } } + + @RequiredArgsConstructor + private class SchemaDefinitionToVersionCacheV2 extends CacheLoader { + @Override + public UUID load(SchemaV2 schema) { + return awsSchemaRegistryClient.getSchemaVersionIdByDefinition( + schema.getSchemaDefinition(), schema.getSchemaName(), schema.getDataFormat()); + } + } } \ No newline at end of file diff --git a/common/src/main/java/com/amazonaws/services/schemaregistry/common/SchemaV2.java b/common/src/main/java/com/amazonaws/services/schemaregistry/common/SchemaV2.java new file mode 100644 index 00000000..ae116ea1 --- /dev/null +++ b/common/src/main/java/com/amazonaws/services/schemaregistry/common/SchemaV2.java @@ -0,0 +1,47 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. + * Licensed 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 com.amazonaws.services.schemaregistry.common; + +import lombok.AllArgsConstructor; +import lombok.Value; +import software.amazon.awssdk.services.glue.model.Compatibility; + +/** + * Schema entity represents a schema and it's properties stored in Glue Schema Registry. + */ +@AllArgsConstructor +@Value +public class SchemaV2 { + /** + * Schema Definition contains the string representation of schema version stored during registration. + */ + private String schemaDefinition; + + /** + * Data Format represents the string notation of data format used during registration of schea. Ex: Avro, JSON etc. + */ + private String dataFormat; + + /** + * Schema Name represents name of the schema under which the schema version was registered. + */ + private String schemaName; + + /** + * Compatibility mode refers to the compatibility settings of the schema. + */ + private Compatibility compatibilityMode; +} diff --git a/cross-region-replication-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/AWSGlueCrossRegionSchemaReplicationConverter.java b/cross-region-replication-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/AWSGlueCrossRegionSchemaReplicationConverter.java index a45563a1..1d50b114 100644 --- a/cross-region-replication-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/AWSGlueCrossRegionSchemaReplicationConverter.java +++ b/cross-region-replication-converter/src/main/java/com/amazonaws/services/crossregion/schemaregistry/kafkaconnect/AWSGlueCrossRegionSchemaReplicationConverter.java @@ -1,6 +1,7 @@ package com.amazonaws.services.crossregion.schemaregistry.kafkaconnect; import com.amazonaws.services.schemaregistry.common.Schema; +import com.amazonaws.services.schemaregistry.common.SchemaV2; import com.amazonaws.services.schemaregistry.common.configs.GlueSchemaRegistryConfiguration; import com.amazonaws.services.schemaregistry.deserializers.GlueSchemaRegistryDeserializerImpl; import com.amazonaws.services.schemaregistry.exception.AWSSchemaRegistryException; @@ -82,11 +83,12 @@ public byte[] fromConnectData(String topic, org.apache.kafka.connect.data.Schema try { byte[] deserializedBytes = deserializer.getData(bytes); - Schema deserializedSchema = deserializer.getSchema(bytes); + SchemaV2 deserializedSchema = deserializer.getSchemaV2(bytes); + System.out.println("COMPATIBILITY MODE #1:" + deserializedSchema.getCompatibilityMode()); //The registry is decided by the configuration in the target region , schema name is the same as the source region // TODO: Prefix topic name with source cluster alias // https://github.com/awslabs/aws-glue-schema-registry/issues/294 - return serializer.encode(topic, deserializedSchema, deserializedBytes); + return serializer.encodeV2(topic, deserializedSchema, deserializedBytes); } catch(GlueSchemaRegistryIncompatibleDataException ex) { //This exception is raised when the header bytes don't have schema id, version byte or compression byte diff --git a/integration-tests/mirrormaker/mm2-configs/mirror-source-connector.properties b/integration-tests/mirrormaker/mm2-configs/mirror-source-connector.properties index 863b52eb..82088386 100644 --- a/integration-tests/mirrormaker/mm2-configs/mirror-source-connector.properties +++ b/integration-tests/mirrormaker/mm2-configs/mirror-source-connector.properties @@ -6,7 +6,7 @@ target.cluster.alias=dst source.cluster.bootstrap.servers=${SOURCE} target.cluster.bootstrap.servers=${DESTINATION} topics=${TOPICS} -tasks.max=1 +tasks.max=3 key.converter=com.amazonaws.services.crossregion.schemaregistry.kafkaconnect.AWSGlueCrossRegionSchemaReplicationConverter value.converter=com.amazonaws.services.crossregion.schemaregistry.kafkaconnect.AWSGlueCrossRegionSchemaReplicationConverter replication.factor=1 diff --git a/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schemareplication/AWSGlueCrossRegionSchemaReplicationIntegrationTest.java b/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schemareplication/AWSGlueCrossRegionSchemaReplicationIntegrationTest.java index 8d66a7d1..1d4c3bb5 100644 --- a/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schemareplication/AWSGlueCrossRegionSchemaReplicationIntegrationTest.java +++ b/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schemareplication/AWSGlueCrossRegionSchemaReplicationIntegrationTest.java @@ -16,6 +16,7 @@ import com.amazonaws.services.schemaregistry.integrationtests.generators.*; import com.amazonaws.services.schemaregistry.integrationtests.properties.GlueSchemaRegistryConnectionProperties; +import com.amazonaws.services.schemaregistry.utils.AWSSchemaRegistryConstants; import com.amazonaws.services.schemaregistry.utils.AvroRecordType; import com.amazonaws.services.schemaregistry.utils.ProtobufMessageType; import com.google.protobuf.DynamicMessage; @@ -65,6 +66,11 @@ public class AWSGlueCrossRegionSchemaReplicationIntegrationTest { private static final String SRC_REGION = GlueSchemaRegistryConnectionProperties.SRC_REGION; private static final String DEST_REGION = GlueSchemaRegistryConnectionProperties.DEST_REGION; private static final String RECORD_TYPE = "GENERIC_RECORD"; + private static final List COMPATIBILITIES = Compatibility.knownValues() + .stream() + .filter(c -> c.toString().equals("NONE") + || c.toString().equals("BACKWARD")) + .collect(Collectors.toList()); private static LocalKafkaClusterHelper srcKafkaClusterHelper = new LocalKafkaClusterHelper(); private static LocalKafkaClusterHelper destKafkaClusterHelper = new LocalKafkaClusterHelper(); private static AwsCredentialsProvider awsCredentialsProvider = DefaultCredentialsProvider.builder() @@ -75,7 +81,12 @@ public class AWSGlueCrossRegionSchemaReplicationIntegrationTest { private static Stream testArgumentsProvider() { Stream.Builder argumentBuilder = Stream.builder(); for (DataFormat dataFormat : DataFormat.knownValues()) { - argumentBuilder.add(Arguments.of(dataFormat, RECORD_TYPE, Compatibility.BACKWARD)); + for (Compatibility compatibility : COMPATIBILITIES) { + for (AWSSchemaRegistryConstants.COMPRESSION compression : + AWSSchemaRegistryConstants.COMPRESSION.values()) { + argumentBuilder.add(Arguments.of(dataFormat, RECORD_TYPE, compatibility, compression)); + } + } } return argumentBuilder.build(); } @@ -105,7 +116,7 @@ public void testProduceConsumeWithoutGlueSchemaRegistry() throws Exception { //Delay added to allow MM2 copy the data to destination cluster //before consuming the records from the destination cluster - Thread.sleep(8000); + Thread.sleep(10000); ConsumerProperties consumerProperties = ConsumerProperties.builder() .topicName(String.format("%s.%s",SRC_CLUSTER_ALIAS, topic)) @@ -149,7 +160,7 @@ public void testProduceConsumeWithSchemaRegistryForAllThreeDataFormats(final Dat //Delay added to allow MM2 copy the data to destination cluster //before consuming the records from the destination cluster - Thread.sleep(8000); + Thread.sleep(10000); ConsumerProperties.ConsumerPropertiesBuilder consumerPropertiesBuilder = ConsumerProperties.builder() .topicName(String.format("%s.%s",SRC_CLUSTER_ALIAS, topic)); diff --git a/serializer-deserializer/src/main/java/com/amazonaws/services/schemaregistry/deserializers/GlueSchemaRegistryDeserializationFacade.java b/serializer-deserializer/src/main/java/com/amazonaws/services/schemaregistry/deserializers/GlueSchemaRegistryDeserializationFacade.java index 9821688f..43b43db0 100644 --- a/serializer-deserializer/src/main/java/com/amazonaws/services/schemaregistry/deserializers/GlueSchemaRegistryDeserializationFacade.java +++ b/serializer-deserializer/src/main/java/com/amazonaws/services/schemaregistry/deserializers/GlueSchemaRegistryDeserializationFacade.java @@ -17,6 +17,7 @@ import com.amazonaws.services.schemaregistry.common.AWSDeserializerInput; import com.amazonaws.services.schemaregistry.common.AWSSchemaRegistryClient; import com.amazonaws.services.schemaregistry.common.Schema; +import com.amazonaws.services.schemaregistry.common.SchemaV2; import com.amazonaws.services.schemaregistry.common.configs.GlueSchemaRegistryConfiguration; import com.amazonaws.services.schemaregistry.exception.GlueSchemaRegistryIncompatibleDataException; import com.amazonaws.services.schemaregistry.exception.AWSSchemaRegistryException; @@ -33,7 +34,9 @@ import software.amazon.awssdk.arns.Arn; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; import software.amazon.awssdk.services.glue.model.DataFormat; +import software.amazon.awssdk.services.glue.model.GetSchemaResponse; import software.amazon.awssdk.services.glue.model.GetSchemaVersionResponse; +import software.amazon.awssdk.services.glue.model.SchemaId; import java.io.Closeable; import java.nio.ByteBuffer; @@ -60,6 +63,9 @@ public class GlueSchemaRegistryDeserializationFacade implements Closeable { @VisibleForTesting protected LoadingCache cache; + @VisibleForTesting + protected LoadingCache cacheV2; + /** * Constructor accepting various dependencies. * @@ -99,12 +105,22 @@ private LoadingCache initializeCache() { .build(new GlueSchemaRegistryDeserializationCacheLoader()); } + + private LoadingCache initializeCacheV2() { + return CacheBuilder + .newBuilder() + .maximumSize(glueSchemaRegistryConfiguration.getCacheSize()) + .refreshAfterWrite(glueSchemaRegistryConfiguration.getTimeToLiveMillis(), TimeUnit.MILLISECONDS) + .build(new GlueSchemaRegistryDeserializationCacheLoaderV2()); + } + public GlueSchemaRegistryDeserializationFacade(@NonNull GlueSchemaRegistryConfiguration configuration, @NonNull AwsCredentialsProvider credentialsProvider) { this.credentialsProvider = credentialsProvider; this.glueSchemaRegistryConfiguration = configuration; this.schemaRegistryClient = new AWSSchemaRegistryClient(credentialsProvider, this.glueSchemaRegistryConfiguration); this.deserializerFactory = new GlueSchemaRegistryDeserializerFactory(); this.cache = initializeCache(); + this.cacheV2 = initializeCacheV2(); } /** @@ -142,6 +158,13 @@ public Schema getSchema(@NonNull byte[] data) { return awsDeserializerSchema.getSchema(); } + public SchemaV2 getSchemaV2(@NonNull byte[] data) { + ByteBuffer byteBuffer = ByteBuffer.wrap(data); + AwsDeserializerSchemaV2 awsDeserializerSchema = getAwsDeserializerSchemaV2(byteBuffer); + + return awsDeserializerSchema.getSchema(); + } + /** * Fetches the schema definition for a the serialized data. * @@ -203,6 +226,22 @@ private AwsDeserializerSchema getAwsDeserializerSchema(@NonNull ByteBuffer buffe return new AwsDeserializerSchema(schemaVersionId, schema); } + /** + * Helper function to return schema version id and schema registry metadata + * + * @param buffer byte buffer to be de-serialized + * @return schema version id and schema registry metadata + */ + private AwsDeserializerSchemaV2 getAwsDeserializerSchemaV2(@NonNull ByteBuffer buffer) { + // Validate the data + GlueSchemaRegistryDeserializerDataParser dataParser = GlueSchemaRegistryDeserializerDataParser.getInstance(); + + UUID schemaVersionId = dataParser.getSchemaVersionId(buffer); + SchemaV2 schema = retrieveSchemaRegistrySchemaV2(schemaVersionId); + + return new AwsDeserializerSchemaV2(schemaVersionId, schema); + } + /** * Gets the schema details for the schema version id from the schema registry. * @@ -222,6 +261,27 @@ private Schema retrieveSchemaRegistrySchema(UUID schemaVersionId) throws AWSSche return schema; } + + + /** + * Gets the schema details for the schema version id from the schema registry. + * + * @param schemaVersionId the schema version Id for the writer schema + * @return the SchemaV2 for the message + * @throws AWSSchemaRegistryException Exception when getting schema by Id from + * schema registry client + */ + private SchemaV2 retrieveSchemaRegistrySchemaV2(UUID schemaVersionId) throws AWSSchemaRegistryException { + SchemaV2 schema; + try { + schema = cacheV2.get(schemaVersionId); + } catch (Exception e) { + throw new AWSSchemaRegistryException(e.getCause()); + } + + return schema; + } + private String getSchemaName(String schemaArn) { Arn arn = Arn.fromString(schemaArn); String resource = arn.resourceAsString(); @@ -250,6 +310,17 @@ private static class AwsDeserializerSchema { } } + @Data + private static class AwsDeserializerSchemaV2 { + private final UUID schemaVersionId; + private final SchemaV2 schema; + + AwsDeserializerSchemaV2(UUID schemaVersionId, SchemaV2 schema) { + this.schemaVersionId = schemaVersionId; + this.schema = schema; + } + } + private class GlueSchemaRegistryDeserializationCacheLoader extends CacheLoader { @Override public Schema load(UUID schemaVersionId) { @@ -258,4 +329,18 @@ public Schema load(UUID schemaVersionId) { return new Schema(response.schemaDefinition(), response.dataFormat().name(), getSchemaName(response.schemaArn())); } } + + private class GlueSchemaRegistryDeserializationCacheLoaderV2 extends CacheLoader { + @Override + public SchemaV2 load(UUID schemaVersionId) { + GetSchemaVersionResponse schemaVersionResponse = schemaRegistryClient.getSchemaVersionResponse(schemaVersionId.toString()); + GetSchemaResponse schemaResponse = schemaRegistryClient.getSchemaResponse(SchemaId.builder() + .schemaArn(schemaVersionResponse.schemaArn()) + .build()); + return new SchemaV2(schemaVersionResponse.schemaDefinition(), + schemaVersionResponse.dataFormat().name(), + getSchemaName(schemaVersionResponse.schemaArn()), + schemaResponse.compatibility()); + } + } } diff --git a/serializer-deserializer/src/main/java/com/amazonaws/services/schemaregistry/deserializers/GlueSchemaRegistryDeserializer.java b/serializer-deserializer/src/main/java/com/amazonaws/services/schemaregistry/deserializers/GlueSchemaRegistryDeserializer.java index 01ab48dc..68dae55b 100644 --- a/serializer-deserializer/src/main/java/com/amazonaws/services/schemaregistry/deserializers/GlueSchemaRegistryDeserializer.java +++ b/serializer-deserializer/src/main/java/com/amazonaws/services/schemaregistry/deserializers/GlueSchemaRegistryDeserializer.java @@ -15,6 +15,7 @@ package com.amazonaws.services.schemaregistry.deserializers; import com.amazonaws.services.schemaregistry.common.Schema; +import com.amazonaws.services.schemaregistry.common.SchemaV2; /** * Entry point to deserialization capabilities of Glue Schema Registry client library. @@ -39,6 +40,15 @@ public interface GlueSchemaRegistryDeserializer { */ Schema getSchema(byte[] data); + /** + * Returns the schema encoded in the byte array by Glue Schema Registry serializer. + * The schema returned is administered by Glue Schema Registry. + * + * @param data byte[] Schema Registry encoded byte array. + * @return schema {@link SchemaV2} A Schema object representing the schema information. + */ + SchemaV2 getSchemaV2(byte[] data); + /** * Determines if the given byte array can be deserialized by Glue Schema Registry deserializer. * @param data byte[] of data. diff --git a/serializer-deserializer/src/main/java/com/amazonaws/services/schemaregistry/deserializers/GlueSchemaRegistryDeserializerImpl.java b/serializer-deserializer/src/main/java/com/amazonaws/services/schemaregistry/deserializers/GlueSchemaRegistryDeserializerImpl.java index 1ea2dab0..69a1bca7 100644 --- a/serializer-deserializer/src/main/java/com/amazonaws/services/schemaregistry/deserializers/GlueSchemaRegistryDeserializerImpl.java +++ b/serializer-deserializer/src/main/java/com/amazonaws/services/schemaregistry/deserializers/GlueSchemaRegistryDeserializerImpl.java @@ -15,6 +15,7 @@ package com.amazonaws.services.schemaregistry.deserializers; import com.amazonaws.services.schemaregistry.common.Schema; +import com.amazonaws.services.schemaregistry.common.SchemaV2; import com.amazonaws.services.schemaregistry.common.configs.GlueSchemaRegistryConfiguration; import com.google.common.annotations.VisibleForTesting; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; @@ -67,6 +68,16 @@ public Schema getSchema(final byte[] data) { return glueSchemaRegistryDeserializationFacade.getSchema(data); } + /** + * {@inheritDoc} + * @param data byte[] Schema Registry encoded byte array. + * @return schema {@link SchemaV2} A Schema object representing the schema information along with compatibility mode. + */ + @Override + public SchemaV2 getSchemaV2(final byte[] data) { + return glueSchemaRegistryDeserializationFacade.getSchemaV2(data); + } + /** * {@inheritDoc} * @param data byte[] of data. diff --git a/serializer-deserializer/src/main/java/com/amazonaws/services/schemaregistry/serializers/GlueSchemaRegistrySerializationFacade.java b/serializer-deserializer/src/main/java/com/amazonaws/services/schemaregistry/serializers/GlueSchemaRegistrySerializationFacade.java index 8adbe70e..a2ba2035 100644 --- a/serializer-deserializer/src/main/java/com/amazonaws/services/schemaregistry/serializers/GlueSchemaRegistrySerializationFacade.java +++ b/serializer-deserializer/src/main/java/com/amazonaws/services/schemaregistry/serializers/GlueSchemaRegistrySerializationFacade.java @@ -14,12 +14,7 @@ */ package com.amazonaws.services.schemaregistry.serializers; -import com.amazonaws.services.schemaregistry.common.AWSSchemaRegistryClient; -import com.amazonaws.services.schemaregistry.common.AWSSchemaRegistryGlueClientRetryPolicyHelper; -import com.amazonaws.services.schemaregistry.common.AWSSerializerInput; -import com.amazonaws.services.schemaregistry.common.GlueSchemaRegistryDataFormatSerializer; -import com.amazonaws.services.schemaregistry.common.Schema; -import com.amazonaws.services.schemaregistry.common.SchemaByDefinitionFetcher; +import com.amazonaws.services.schemaregistry.common.*; import com.amazonaws.services.schemaregistry.common.configs.GlueSchemaRegistryConfiguration; import com.amazonaws.services.schemaregistry.exception.AWSSchemaRegistryException; import com.amazonaws.services.schemaregistry.utils.AWSSchemaRegistryConstants; @@ -28,6 +23,7 @@ import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.services.glue.model.Compatibility; import software.amazon.awssdk.services.glue.model.DataFormat; import java.util.HashMap; @@ -88,6 +84,24 @@ public UUID getOrRegisterSchemaVersion(@NonNull AWSSerializerInput serializerInp return schemaVersionId; } + @SneakyThrows + public UUID getOrRegisterSchemaVersionV2(@NonNull AWSSerializerInput serializerInput) { + String schemaDefinition = serializerInput.getSchemaDefinition(); + String schemaName = serializerInput.getSchemaName(); + String transportName = serializerInput.getTransportName(); + Compatibility compatibility = serializerInput.getCompatibility(); + String dataFormat = serializerInput.getDataFormat(); + + System.out.println("COMPATIBILITY MODE #3:" + compatibility); + + Map metadata = constructSchemaVersionMetadata(transportName); + + UUID schemaVersionId = + schemaByDefinitionFetcher.getORRegisterSchemaVersionIdV2(schemaDefinition, schemaName, dataFormat, + compatibility, metadata); + return schemaVersionId; + } + private Map constructSchemaVersionMetadata(String transportName) { Map metadata = new HashMap<>(); metadata.put(AWSSchemaRegistryConstants.TRANSPORT_METADATA_KEY, transportName); @@ -132,6 +146,32 @@ public byte[] encode(String transportName, return serializationDataEncoder.write(data, schemaVersionId); } + public byte[] encodeV2(String transportName, + SchemaV2 schema, + byte[] data) { + final String dataFormat = schema.getDataFormat(); + final String schemaDefinition = schema.getSchemaDefinition(); + final String schemaName = schema.getSchemaName(); + + GlueSchemaRegistryDataFormatSerializer dataFormatSerializer = + glueSchemaRegistrySerializerFactory.getInstance( + DataFormat.valueOf(dataFormat), glueSchemaRegistryConfiguration); + //Ensures the data bytes conform to schema definition for data formats like JSON. + dataFormatSerializer.validate(schemaDefinition, data); + + System.out.println("COMPATIBILITY MODE #2:" + schema.getCompatibilityMode()); + + UUID schemaVersionId = getOrRegisterSchemaVersionV2(AWSSerializerInput.builder() + .schemaDefinition(schemaDefinition) + .schemaName(schemaName) + .dataFormat(dataFormat) + .transportName(transportName) + .compatibility(schema.getCompatibilityMode()) + .build()); + + return serializationDataEncoder.write(data, schemaVersionId); + } + public String getSchemaDefinition(DataFormat dataFormat, @NonNull Object data) { GlueSchemaRegistryDataFormatSerializer dataFormatSerializer = diff --git a/serializer-deserializer/src/main/java/com/amazonaws/services/schemaregistry/serializers/GlueSchemaRegistrySerializer.java b/serializer-deserializer/src/main/java/com/amazonaws/services/schemaregistry/serializers/GlueSchemaRegistrySerializer.java index 761d0444..55e8eb12 100644 --- a/serializer-deserializer/src/main/java/com/amazonaws/services/schemaregistry/serializers/GlueSchemaRegistrySerializer.java +++ b/serializer-deserializer/src/main/java/com/amazonaws/services/schemaregistry/serializers/GlueSchemaRegistrySerializer.java @@ -15,6 +15,7 @@ package com.amazonaws.services.schemaregistry.serializers; import com.amazonaws.services.schemaregistry.common.Schema; +import com.amazonaws.services.schemaregistry.common.SchemaV2; /** * Entry point to serialization capabilities of Glue Schema Registry client library. @@ -28,4 +29,13 @@ public interface GlueSchemaRegistrySerializer { * @return encodedData Schema Registry Encoded byte array which can only be decoded by Schema Registry de-serializer. */ byte[] encode(String transportName, Schema schema, byte[] data); + + /** + * Encodes the given byte array with Schema Registry header information. + * The header contains a reference to the Schema that corresponds to the data. + * @param schema {@link com.amazonaws.services.schemaregistry.common.SchemaV2} A Schema object representing the schema information. + * @param data Byte array consisting of customer data that needs to be encoded. + * @return encodedData Schema Registry Encoded byte array which can only be decoded by Schema Registry de-serializer. + */ + byte[] encodeV2(String transportName, SchemaV2 schema, byte[] data); } diff --git a/serializer-deserializer/src/main/java/com/amazonaws/services/schemaregistry/serializers/GlueSchemaRegistrySerializerImpl.java b/serializer-deserializer/src/main/java/com/amazonaws/services/schemaregistry/serializers/GlueSchemaRegistrySerializerImpl.java index bbfe83a1..8b948a6d 100644 --- a/serializer-deserializer/src/main/java/com/amazonaws/services/schemaregistry/serializers/GlueSchemaRegistrySerializerImpl.java +++ b/serializer-deserializer/src/main/java/com/amazonaws/services/schemaregistry/serializers/GlueSchemaRegistrySerializerImpl.java @@ -15,9 +15,11 @@ package com.amazonaws.services.schemaregistry.serializers; import com.amazonaws.services.schemaregistry.common.Schema; +import com.amazonaws.services.schemaregistry.common.SchemaV2; import com.amazonaws.services.schemaregistry.common.configs.GlueSchemaRegistryConfiguration; import com.google.common.annotations.VisibleForTesting; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.services.glue.model.Compatibility; import javax.annotation.Nullable; @@ -82,4 +84,26 @@ public byte[] encode(@Nullable String transportName, Schema schema, byte[] data) data ); } + + /** + * Converts the given data byte array to be Glue Schema Registry compatible byte array. + * If the auto-registration setting is turned on, a new schema definitions will be automatically registered. + * Note that the encoded byte array can only be decoded by a + * Glue Schema Registry de-serializer ({@link com.amazonaws.services.schemaregistry.deserializers.GlueSchemaRegistryDeserializer} + * + * @param transportName {@link String} Name of the transport channel for the message. + * This will be used to add metadata to schema. + * If null, "default-stream" will be used. + * @param schema {@link SchemaV2} A schema object. + * @param data byte array of data that needs to be encoded. + * @return Encoded Glue Schema Registry compatible byte array. + */ + @Override + public byte[] encodeV2(@Nullable String transportName, SchemaV2 schema, byte[] data) { + return glueSchemaRegistrySerializationFacade.encodeV2( + transportName, + schema, + data + ); + } } From dc9c91e1ef7c1286f7c59a45be37466e2ef4cf61 Mon Sep 17 00:00:00 2001 From: Subham Rakshit Date: Tue, 9 Jul 2024 10:46:47 +0100 Subject: [PATCH 24/41] MM2 will pickup topics prefixed with SchemaReplicationTests only --- integration-tests/docker-compose.yml | 2 +- .../mirror-source-connector.properties | 2 - integration-tests/run-local-tests.sh | 3 +- ...egionSchemaReplicationIntegrationTest.java | 2 +- mongo-kafka-connect-1.3.0-all.jar | Bin 0 -> 2310505 bytes out.log | 1 + wget-log | 61 ++++++++++++++++++ 7 files changed, 66 insertions(+), 5 deletions(-) create mode 100644 mongo-kafka-connect-1.3.0-all.jar create mode 100644 out.log create mode 100644 wget-log diff --git a/integration-tests/docker-compose.yml b/integration-tests/docker-compose.yml index ea1ea579..bb5718b3 100644 --- a/integration-tests/docker-compose.yml +++ b/integration-tests/docker-compose.yml @@ -48,7 +48,7 @@ services: - ALLOW_PLAINTEXT_LISTENER=yes - SOURCE=kafka:29092 - DESTINATION=kafka-target:29092 - - TOPICS=^SchemaRegistryTests.* + - TOPICS=^SchemaReplicationTests.* - ACLS_ENABLED=false - AWS_PROFILE=default volumes: diff --git a/integration-tests/mirrormaker/mm2-configs/mirror-source-connector.properties b/integration-tests/mirrormaker/mm2-configs/mirror-source-connector.properties index 82088386..df5c1d68 100644 --- a/integration-tests/mirrormaker/mm2-configs/mirror-source-connector.properties +++ b/integration-tests/mirrormaker/mm2-configs/mirror-source-connector.properties @@ -24,13 +24,11 @@ key.converter.target.endpoint=https://glue.us-east-2.amazonaws.com key.converter.target.region=us-east-2 key.converter.registry.name=default-registry key.converter.schemaAutoRegistrationEnabled=true -#key.converter.avroRecordType=GENERIC_RECORD value.converter.source.endpoint=https://glue.us-east-1.amazonaws.com value.converter.source.region=us-east-1 value.converter.target.endpoint=https://glue.us-east-2.amazonaws.com value.converter.target.region=us-east-2 value.converter.registry.name=default-registry value.converter.schemaAutoRegistrationEnabled=true -#value.converter.avroRecordType=GENERIC_RECORD errors.log.enable=true errors.log.include.messages=true \ No newline at end of file diff --git a/integration-tests/run-local-tests.sh b/integration-tests/run-local-tests.sh index a8a43f77..16c9c765 100755 --- a/integration-tests/run-local-tests.sh +++ b/integration-tests/run-local-tests.sh @@ -116,7 +116,8 @@ cleanUpConnectFiles() { cleanUpDockerResources || true # Start Kafka using docker command asynchronously docker-compose up --no-attach localstack & -sleep 10 +## Pause to allow docker build to complete +sleep 60 ## Run mvn tests for Kafka, Kinesis Platforms and Schema Replication cd .. && mvn --file integration-tests/pom.xml verify -Psurefire -X && cd integration-tests cleanUpDockerResources diff --git a/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schemareplication/AWSGlueCrossRegionSchemaReplicationIntegrationTest.java b/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schemareplication/AWSGlueCrossRegionSchemaReplicationIntegrationTest.java index 1d4c3bb5..7d691e8b 100644 --- a/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schemareplication/AWSGlueCrossRegionSchemaReplicationIntegrationTest.java +++ b/integration-tests/src/test/java/com/amazonaws/services/schemaregistry/integrationtests/schemareplication/AWSGlueCrossRegionSchemaReplicationIntegrationTest.java @@ -60,7 +60,7 @@ @Slf4j public class AWSGlueCrossRegionSchemaReplicationIntegrationTest { private static final String SRC_CLUSTER_ALIAS = "src"; - private static final String TOPIC_NAME_PREFIX = "SchemaRegistryTests"; + private static final String TOPIC_NAME_PREFIX = "SchemaReplicationTests"; private static final String SCHEMA_REGISTRY_SRC_ENDPOINT_OVERRIDE = GlueSchemaRegistryConnectionProperties.SRC_ENDPOINT; private static final String SCHEMA_REGISTRY_DEST_ENDPOINT_OVERRIDE = GlueSchemaRegistryConnectionProperties.DEST_ENDPOINT; private static final String SRC_REGION = GlueSchemaRegistryConnectionProperties.SRC_REGION; diff --git a/mongo-kafka-connect-1.3.0-all.jar b/mongo-kafka-connect-1.3.0-all.jar new file mode 100644 index 0000000000000000000000000000000000000000..0b4673a673bff8423b8b5e2d95c14ca3daae905d GIT binary patch literal 2310505 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%0A6q4< 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⪙3!=ZixDYl=aX*z*e@ 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%0ASn#Eew|p^KGt`Ae!Njt~M2S)2{z}V$ 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