diff --git a/buildSrc/src/main/java/org/springframework/boot/build/context/properties/DocumentConfigurationProperties.java b/buildSrc/src/main/java/org/springframework/boot/build/context/properties/DocumentConfigurationProperties.java index 8019eb3a27..cedb706ea0 100644 --- a/buildSrc/src/main/java/org/springframework/boot/build/context/properties/DocumentConfigurationProperties.java +++ b/buildSrc/src/main/java/org/springframework/boot/build/context/properties/DocumentConfigurationProperties.java @@ -171,6 +171,7 @@ public class DocumentConfigurationProperties extends DefaultTask { prefix.accept("spring.integration"); prefix.accept("spring.jms"); prefix.accept("spring.kafka"); + prefix.accept("spring.pulsar"); prefix.accept("spring.rabbitmq"); prefix.accept("spring.hazelcast"); prefix.accept("spring.webservices"); diff --git a/spring-boot-project/spring-boot-autoconfigure/build.gradle b/spring-boot-project/spring-boot-autoconfigure/build.gradle index a30895a521..b48da7b7bf 100644 --- a/spring-boot-project/spring-boot-autoconfigure/build.gradle +++ b/spring-boot-project/spring-boot-autoconfigure/build.gradle @@ -179,6 +179,8 @@ dependencies { optional("org.springframework.data:spring-data-redis") optional("org.springframework.graphql:spring-graphql") optional("org.springframework.hateoas:spring-hateoas") + optional("org.springframework.pulsar:spring-pulsar") + optional("org.springframework.pulsar:spring-pulsar-reactive") optional("org.springframework.security:spring-security-acl") optional("org.springframework.security:spring-security-config") optional("org.springframework.security:spring-security-data") { @@ -255,6 +257,7 @@ dependencies { testImplementation("org.testcontainers:junit-jupiter") testImplementation("org.testcontainers:mongodb") testImplementation("org.testcontainers:neo4j") + testImplementation("org.testcontainers:pulsar") testImplementation("org.testcontainers:testcontainers") testImplementation("org.yaml:snakeyaml") diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/DeadLetterPolicyMapper.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/DeadLetterPolicyMapper.java new file mode 100644 index 0000000000..fc4a4f64b6 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/DeadLetterPolicyMapper.java @@ -0,0 +1,49 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.pulsar; + +import org.apache.pulsar.client.api.DeadLetterPolicy; +import org.apache.pulsar.client.api.DeadLetterPolicy.DeadLetterPolicyBuilder; + +import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.util.Assert; + +/** + * Helper class used to map {@link PulsarProperties.Consumer.DeadLetterPolicy dead letter + * policy properties}. + * + * @author Chris Bono + * @author Phillip Webb + */ +final class DeadLetterPolicyMapper { + + private DeadLetterPolicyMapper() { + } + + static DeadLetterPolicy map(PulsarProperties.Consumer.DeadLetterPolicy policy) { + Assert.state(policy.getMaxRedeliverCount() > 0, + "Pulsar DeadLetterPolicy must have a positive 'max-redelivery-count' property value"); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + DeadLetterPolicyBuilder builder = DeadLetterPolicy.builder(); + map.from(policy::getMaxRedeliverCount).to(builder::maxRedeliverCount); + map.from(policy::getRetryLetterTopic).to(builder::retryLetterTopic); + map.from(policy::getDeadLetterTopic).to(builder::deadLetterTopic); + map.from(policy::getInitialSubscriptionName).to(builder::initialSubscriptionName); + return builder.build(); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarAutoConfiguration.java new file mode 100644 index 0000000000..9ed6ae3b09 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarAutoConfiguration.java @@ -0,0 +1,196 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.pulsar; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.pulsar.client.api.ConsumerBuilder; +import org.apache.pulsar.client.api.ProducerBuilder; +import org.apache.pulsar.client.api.PulsarClient; +import org.apache.pulsar.client.api.ReaderBuilder; +import org.apache.pulsar.client.api.interceptor.ProducerInterceptor; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.util.LambdaSafe; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.pulsar.annotation.EnablePulsar; +import org.springframework.pulsar.config.ConcurrentPulsarListenerContainerFactory; +import org.springframework.pulsar.config.DefaultPulsarReaderContainerFactory; +import org.springframework.pulsar.config.PulsarAnnotationSupportBeanNames; +import org.springframework.pulsar.core.CachingPulsarProducerFactory; +import org.springframework.pulsar.core.ConsumerBuilderCustomizer; +import org.springframework.pulsar.core.DefaultPulsarConsumerFactory; +import org.springframework.pulsar.core.DefaultPulsarProducerFactory; +import org.springframework.pulsar.core.DefaultPulsarReaderFactory; +import org.springframework.pulsar.core.ProducerBuilderCustomizer; +import org.springframework.pulsar.core.PulsarConsumerFactory; +import org.springframework.pulsar.core.PulsarProducerFactory; +import org.springframework.pulsar.core.PulsarReaderFactory; +import org.springframework.pulsar.core.PulsarTemplate; +import org.springframework.pulsar.core.ReaderBuilderCustomizer; +import org.springframework.pulsar.core.SchemaResolver; +import org.springframework.pulsar.core.TopicResolver; +import org.springframework.pulsar.listener.PulsarContainerProperties; +import org.springframework.pulsar.reader.PulsarReaderContainerProperties; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for Apache Pulsar. + * + * @author Chris Bono + * @author Soby Chacko + * @author Alexander Preuß + * @author Phillip Webb + * @since 3.2.0 + */ +@AutoConfiguration +@ConditionalOnClass({ PulsarClient.class, PulsarTemplate.class }) +@Import(PulsarConfiguration.class) +public class PulsarAutoConfiguration { + + private PulsarProperties properties; + + private PulsarPropertiesMapper propertiesMapper; + + PulsarAutoConfiguration(PulsarProperties properties) { + this.properties = properties; + this.propertiesMapper = new PulsarPropertiesMapper(properties); + } + + @Bean + @ConditionalOnMissingBean(PulsarProducerFactory.class) + @ConditionalOnProperty(name = "spring.pulsar.producer.cache.enabled", havingValue = "false") + DefaultPulsarProducerFactory pulsarProducerFactory(PulsarClient pulsarClient, TopicResolver topicResolver, + ObjectProvider> customizersProvider) { + List> lambdaSafeCustomizers = lambdaSafeProducerBuilderCustomizers( + customizersProvider); + return new DefaultPulsarProducerFactory<>(pulsarClient, this.properties.getProducer().getTopicName(), + lambdaSafeCustomizers, topicResolver); + } + + @Bean + @ConditionalOnMissingBean(PulsarProducerFactory.class) + @ConditionalOnProperty(name = "spring.pulsar.producer.cache.enabled", havingValue = "true", matchIfMissing = true) + CachingPulsarProducerFactory cachingPulsarProducerFactory(PulsarClient pulsarClient, TopicResolver topicResolver, + ObjectProvider> customizersProvider) { + PulsarProperties.Producer.Cache cacheProperties = this.properties.getProducer().getCache(); + List> lambdaSafeCustomizers = lambdaSafeProducerBuilderCustomizers( + customizersProvider); + return new CachingPulsarProducerFactory<>(pulsarClient, this.properties.getProducer().getTopicName(), + lambdaSafeCustomizers, topicResolver, cacheProperties.getExpireAfterAccess(), + cacheProperties.getMaximumSize(), cacheProperties.getInitialCapacity()); + } + + private List> lambdaSafeProducerBuilderCustomizers( + ObjectProvider> customizersProvider) { + List> customizers = new ArrayList<>(); + customizers.add(this.propertiesMapper::customizeProducerBuilder); + customizers.addAll(customizersProvider.orderedStream().toList()); + return List.of((builder) -> applyProducerBuilderCustomizers(customizers, builder)); + } + + @SuppressWarnings("unchecked") + private void applyProducerBuilderCustomizers(List> customizers, + ProducerBuilder builder) { + LambdaSafe.callbacks(ProducerBuilderCustomizer.class, customizers, builder) + .invoke((customizer) -> customizer.customize(builder)); + } + + @Bean + @ConditionalOnMissingBean + PulsarTemplate pulsarTemplate(PulsarProducerFactory pulsarProducerFactory, + ObjectProvider producerInterceptors, SchemaResolver schemaResolver, + TopicResolver topicResolver) { + return new PulsarTemplate<>(pulsarProducerFactory, producerInterceptors.orderedStream().toList(), + schemaResolver, topicResolver, this.properties.getTemplate().isObservationsEnabled()); + } + + @Bean + @ConditionalOnMissingBean(PulsarConsumerFactory.class) + DefaultPulsarConsumerFactory pulsarConsumerFactory(PulsarClient pulsarClient, + ObjectProvider> customizersProvider) { + List> customizers = new ArrayList<>(); + customizers.add(this.propertiesMapper::customizeConsumerBuilder); + customizers.addAll(customizersProvider.orderedStream().toList()); + List> lambdaSafeCustomizers = List + .of((builder) -> applyConsumerBuilderCustomizers(customizers, builder)); + return new DefaultPulsarConsumerFactory<>(pulsarClient, lambdaSafeCustomizers); + } + + @SuppressWarnings("unchecked") + private void applyConsumerBuilderCustomizers(List> customizers, + ConsumerBuilder builder) { + LambdaSafe.callbacks(ConsumerBuilderCustomizer.class, customizers, builder) + .invoke((customizer) -> customizer.customize(builder)); + } + + @Bean + @ConditionalOnMissingBean(name = "pulsarListenerContainerFactory") + ConcurrentPulsarListenerContainerFactory pulsarListenerContainerFactory( + PulsarConsumerFactory pulsarConsumerFactory, SchemaResolver schemaResolver, + TopicResolver topicResolver) { + PulsarContainerProperties containerProperties = new PulsarContainerProperties(); + containerProperties.setSchemaResolver(schemaResolver); + containerProperties.setTopicResolver(topicResolver); + this.propertiesMapper.customizeContainerProperties(containerProperties); + return new ConcurrentPulsarListenerContainerFactory<>(pulsarConsumerFactory, containerProperties); + } + + @Bean + @ConditionalOnMissingBean(PulsarReaderFactory.class) + DefaultPulsarReaderFactory pulsarReaderFactory(PulsarClient pulsarClient, + ObjectProvider> customizersProvider) { + List> customizers = new ArrayList<>(); + customizers.add(this.propertiesMapper::customizeReaderBuilder); + customizers.addAll(customizersProvider.orderedStream().toList()); + List> lambdaSafeCustomizers = List + .of((builder) -> applyReaderBuilderCustomizers(customizers, builder)); + return new DefaultPulsarReaderFactory<>(pulsarClient, lambdaSafeCustomizers); + } + + @SuppressWarnings("unchecked") + private void applyReaderBuilderCustomizers(List> customizers, ReaderBuilder builder) { + LambdaSafe.callbacks(ReaderBuilderCustomizer.class, customizers, builder) + .invoke((customizer) -> customizer.customize(builder)); + } + + @Bean + @ConditionalOnMissingBean(name = "pulsarReaderContainerFactory") + DefaultPulsarReaderContainerFactory pulsarReaderContainerFactory(PulsarReaderFactory pulsarReaderFactory, + SchemaResolver schemaResolver) { + PulsarReaderContainerProperties readerContainerProperties = new PulsarReaderContainerProperties(); + readerContainerProperties.setSchemaResolver(schemaResolver); + this.propertiesMapper.customizeReaderContainerProperties(readerContainerProperties); + return new DefaultPulsarReaderContainerFactory<>(pulsarReaderFactory, readerContainerProperties); + } + + @Configuration(proxyBeanMethods = false) + @EnablePulsar + @ConditionalOnMissingBean(name = { PulsarAnnotationSupportBeanNames.PULSAR_LISTENER_ANNOTATION_PROCESSOR_BEAN_NAME, + PulsarAnnotationSupportBeanNames.PULSAR_READER_ANNOTATION_PROCESSOR_BEAN_NAME }) + static class EnablePulsarConfiguration { + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarConfiguration.java new file mode 100644 index 0000000000..14551bed70 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarConfiguration.java @@ -0,0 +1,173 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.pulsar; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.pulsar.client.admin.PulsarAdminBuilder; +import org.apache.pulsar.client.api.ClientBuilder; +import org.apache.pulsar.client.api.PulsarClient; +import org.apache.pulsar.client.api.PulsarClientException; +import org.apache.pulsar.client.api.Schema; +import org.apache.pulsar.common.schema.SchemaType; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.pulsar.PulsarProperties.Defaults.SchemaInfo; +import org.springframework.boot.autoconfigure.pulsar.PulsarProperties.Defaults.TypeMapping; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.util.LambdaSafe; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.pulsar.core.DefaultPulsarClientFactory; +import org.springframework.pulsar.core.DefaultSchemaResolver; +import org.springframework.pulsar.core.DefaultTopicResolver; +import org.springframework.pulsar.core.PulsarAdminBuilderCustomizer; +import org.springframework.pulsar.core.PulsarAdministration; +import org.springframework.pulsar.core.PulsarClientBuilderCustomizer; +import org.springframework.pulsar.core.PulsarClientFactory; +import org.springframework.pulsar.core.SchemaResolver; +import org.springframework.pulsar.core.SchemaResolver.SchemaResolverCustomizer; +import org.springframework.pulsar.core.TopicResolver; +import org.springframework.pulsar.function.PulsarFunction; +import org.springframework.pulsar.function.PulsarFunctionAdministration; +import org.springframework.pulsar.function.PulsarSink; +import org.springframework.pulsar.function.PulsarSource; + +/** + * Common configuration used by both {@link PulsarAutoConfiguration} and + * {@link PulsarReactiveAutoConfiguration}. A separate configuration class is used so that + * {@link PulsarAutoConfiguration} can be excluded for reactive only application. + * + * @author Chris Bono + * @author Phillip Webb + */ +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(PulsarProperties.class) +class PulsarConfiguration { + + private final PulsarProperties properties; + + private final PulsarPropertiesMapper propertiesMapper; + + PulsarConfiguration(PulsarProperties properties) { + this.properties = properties; + this.propertiesMapper = new PulsarPropertiesMapper(properties); + } + + @Bean + @ConditionalOnMissingBean(PulsarClientFactory.class) + DefaultPulsarClientFactory pulsarClientFactory(ObjectProvider customizersProvider) { + List allCustomizers = new ArrayList<>(); + allCustomizers.add(this.propertiesMapper::customizeClientBuilder); + allCustomizers.addAll(customizersProvider.orderedStream().toList()); + DefaultPulsarClientFactory clientFactory = new DefaultPulsarClientFactory( + (clientBuilder) -> applyClientBuilderCustomizers(allCustomizers, clientBuilder)); + return clientFactory; + } + + private void applyClientBuilderCustomizers(List customizers, + ClientBuilder clientBuilder) { + customizers.forEach((customizer) -> customizer.customize(clientBuilder)); + } + + @Bean + @ConditionalOnMissingBean + PulsarClient pulsarClient(PulsarClientFactory clientFactory) throws PulsarClientException { + return clientFactory.createClient(); + } + + @Bean + @ConditionalOnMissingBean + PulsarAdministration pulsarAdministration( + ObjectProvider pulsarAdminBuilderCustomizers) { + List allCustomizers = new ArrayList<>(); + allCustomizers.add(this.propertiesMapper::customizeAdminBuilder); + allCustomizers.addAll(pulsarAdminBuilderCustomizers.orderedStream().toList()); + return new PulsarAdministration((adminBuilder) -> applyAdminBuilderCustomizers(allCustomizers, adminBuilder)); + } + + private void applyAdminBuilderCustomizers(List customizers, + PulsarAdminBuilder adminBuilder) { + customizers.forEach((customizer) -> customizer.customize(adminBuilder)); + } + + @Bean + @ConditionalOnMissingBean(SchemaResolver.class) + DefaultSchemaResolver pulsarSchemaResolver(ObjectProvider> schemaResolverCustomizers) { + DefaultSchemaResolver schemaResolver = new DefaultSchemaResolver(); + addCustomSchemaMappings(schemaResolver, this.properties.getDefaults().getTypeMappings()); + applySchemaResolverCustomizers(schemaResolverCustomizers.orderedStream().toList(), schemaResolver); + return schemaResolver; + } + + private void addCustomSchemaMappings(DefaultSchemaResolver schemaResolver, List typeMappings) { + if (typeMappings != null) { + typeMappings.forEach((typeMapping) -> addCustomSchemaMapping(schemaResolver, typeMapping)); + } + } + + private void addCustomSchemaMapping(DefaultSchemaResolver schemaResolver, TypeMapping typeMapping) { + SchemaInfo schemaInfo = typeMapping.schemaInfo(); + if (schemaInfo != null) { + Class messageType = typeMapping.messageType(); + SchemaType schemaType = schemaInfo.schemaType(); + Class messageKeyType = schemaInfo.messageKeyType(); + Schema schema = schemaResolver.resolveSchema(schemaType, messageType, messageKeyType).orElseThrow(); + schemaResolver.addCustomSchemaMapping(typeMapping.messageType(), schema); + } + } + + @SuppressWarnings("unchecked") + private void applySchemaResolverCustomizers(List> customizers, + DefaultSchemaResolver schemaResolver) { + LambdaSafe.callbacks(SchemaResolverCustomizer.class, customizers, schemaResolver) + .invoke((customizer) -> customizer.customize(schemaResolver)); + } + + @Bean + @ConditionalOnMissingBean(TopicResolver.class) + DefaultTopicResolver pulsarTopicResolver() { + DefaultTopicResolver topicResolver = new DefaultTopicResolver(); + List typeMappings = this.properties.getDefaults().getTypeMappings(); + if (typeMappings != null) { + typeMappings.forEach((typeMapping) -> addCustomTopicMapping(topicResolver, typeMapping)); + } + return topicResolver; + } + + private void addCustomTopicMapping(DefaultTopicResolver topicResolver, TypeMapping typeMapping) { + String topicName = typeMapping.topicName(); + if (topicName != null) { + topicResolver.addCustomTopicMapping(typeMapping.messageType(), topicName); + } + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(name = "spring.pulsar.function.enabled", havingValue = "true", matchIfMissing = true) + PulsarFunctionAdministration pulsarFunctionAdministration(PulsarAdministration pulsarAdministration, + ObjectProvider pulsarFunctions, ObjectProvider pulsarSinks, + ObjectProvider pulsarSources) { + PulsarProperties.Function properties = this.properties.getFunction(); + return new PulsarFunctionAdministration(pulsarAdministration, pulsarFunctions, pulsarSinks, pulsarSources, + properties.isFailFast(), properties.isPropagateFailures(), properties.isPropagateStopFailures()); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarProperties.java new file mode 100644 index 0000000000..3ed73df57f --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarProperties.java @@ -0,0 +1,890 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.pulsar; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +import org.apache.pulsar.client.api.CompressionType; +import org.apache.pulsar.client.api.HashingScheme; +import org.apache.pulsar.client.api.MessageRoutingMode; +import org.apache.pulsar.client.api.ProducerAccessMode; +import org.apache.pulsar.client.api.RegexSubscriptionMode; +import org.apache.pulsar.client.api.SubscriptionInitialPosition; +import org.apache.pulsar.client.api.SubscriptionMode; +import org.apache.pulsar.client.api.SubscriptionType; +import org.apache.pulsar.common.schema.SchemaType; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; +import org.springframework.util.Assert; + +/** + * Configuration properties Apache Pulsar. + * + * @author Chris Bono + * @author Phillip Webb + * @since 3.2.0 + */ +@ConfigurationProperties("spring.pulsar") +public class PulsarProperties { + + private final Client client = new Client(); + + private final Admin admin = new Admin(); + + private final Defaults defaults = new Defaults(); + + private final Function function = new Function(); + + private final Producer producer = new Producer(); + + private final Consumer consumer = new Consumer(); + + private final Listener listener = new Listener(); + + private final Reader reader = new Reader(); + + private final Template template = new Template(); + + public Client getClient() { + return this.client; + } + + public Admin getAdmin() { + return this.admin; + } + + public Defaults getDefaults() { + return this.defaults; + } + + public Producer getProducer() { + return this.producer; + } + + public Consumer getConsumer() { + return this.consumer; + } + + public Listener getListener() { + return this.listener; + } + + public Reader getReader() { + return this.reader; + } + + public Function getFunction() { + return this.function; + } + + public Template getTemplate() { + return this.template; + } + + public static class Client { + + /** + * Pulsar service URL in the format '(pulsar|pulsar+ssl)://host:port'. + */ + private String serviceUrl = "pulsar://localhost:6650"; + + /** + * Client operation timeout. + */ + private Duration operationTimeout = Duration.ofSeconds(30); + + /** + * Client lookup timeout. + */ + private Duration lookupTimeout = Duration.ofMillis(-1); // FIXME + + /** + * Duration to wait for a connection to a broker to be established. + */ + private Duration connectionTimeout = Duration.ofSeconds(10); + + /** + * Authentication settings. + */ + private final Authentication authentication = new Authentication(); + + public String getServiceUrl() { + return this.serviceUrl; + } + + public void setServiceUrl(String serviceUrl) { + this.serviceUrl = serviceUrl; + } + + public Duration getOperationTimeout() { + return this.operationTimeout; + } + + public void setOperationTimeout(Duration operationTimeout) { + this.operationTimeout = operationTimeout; + } + + public Duration getLookupTimeout() { + return this.lookupTimeout; + } + + public void setLookupTimeout(Duration lookupTimeout) { + this.lookupTimeout = lookupTimeout; + } + + public Duration getConnectionTimeout() { + return this.connectionTimeout; + } + + public void setConnectionTimeout(Duration connectionTimeout) { + this.connectionTimeout = connectionTimeout; + } + + public Authentication getAuthentication() { + return this.authentication; + } + + } + + public static class Admin { + + /** + * Pulsar web URL for the admin endpoint in the format '(http|https)://host:port'. + */ + private String serviceUrl = "http://localhost:8080"; + + /** + * Duration to wait for a connection to server to be established. + */ + private Duration connectionTimeout = Duration.ofMinutes(1); + + /** + * Server response read time out for any request. + */ + private Duration readTimeout = Duration.ofMinutes(1); + + /** + * Server request time out for any request. + */ + private Duration requestTimeout = Duration.ofMinutes(5); + + /** + * Authentication settings. + */ + private final Authentication authentication = new Authentication(); + + public String getServiceUrl() { + return this.serviceUrl; + } + + public void setServiceUrl(String serviceUrl) { + this.serviceUrl = serviceUrl; + } + + public Duration getConnectionTimeout() { + return this.connectionTimeout; + } + + public void setConnectionTimeout(Duration connectionTimeout) { + this.connectionTimeout = connectionTimeout; + } + + public Duration getReadTimeout() { + return this.readTimeout; + } + + public void setReadTimeout(Duration readTimeout) { + this.readTimeout = readTimeout; + } + + public Duration getRequestTimeout() { + return this.requestTimeout; + } + + public void setRequestTimeout(Duration requestTimeout) { + this.requestTimeout = requestTimeout; + } + + public Authentication getAuthentication() { + return this.authentication; + } + + } + + public static class Defaults { + + /** + * List of mappings from message type to topic name and schema info to use as a + * defaults when a topic name and/or schema is not explicitly specified when + * producing or consuming messages of the mapped type. + */ + private List typeMappings = new ArrayList<>(); + + public List getTypeMappings() { + return this.typeMappings; + } + + public void setTypeMappings(List typeMappings) { + this.typeMappings = typeMappings; + } + + /** + * A mapping from message type to topic and/or schema info to use (at least one of + * {@code topicName} or {@code schemaInfo} must be specified. + * + * @param messageType the message type + * @param topicName the topic name + * @param schemaInfo the schema info + */ + public record TypeMapping(Class messageType, String topicName, SchemaInfo schemaInfo) { + + public TypeMapping { + Assert.notNull(messageType, "messageType must not be null"); + Assert.isTrue(topicName != null || schemaInfo != null, + "At least one of topicName or schemaInfo must not be null"); + } + + } + + /** + * Represents a schema - holds enough information to construct an actual schema + * instance. + * + * @param schemaType schema type + * @param messageKeyType message key type (required for key value type) + */ + public record SchemaInfo(SchemaType schemaType, Class messageKeyType) { + + public SchemaInfo { + Assert.notNull(schemaType, "schemaType must not be null"); + Assert.isTrue(schemaType != SchemaType.NONE, "schemaType 'NONE' not supported"); + Assert.isTrue(messageKeyType == null || schemaType == SchemaType.KEY_VALUE, + "messageKeyType can only be set when schemaType is KEY_VALUE"); + } + + } + + } + + public static class Function { + + /** + * Whether to stop processing further function creates/updates when a failure + * occurs. + */ + private boolean failFast = true; + + /** + * Whether to throw an exception if any failure is encountered during server + * startup while creating/updating functions. + */ + private boolean propagateFailures = true; + + /** + * Whether to throw an exception if any failure is encountered during server + * shutdown while enforcing stop policy on functions. + */ + private boolean propagateStopFailures = false; + + public boolean isFailFast() { + return this.failFast; + } + + public void setFailFast(boolean failFast) { + this.failFast = failFast; + } + + public boolean isPropagateFailures() { + return this.propagateFailures; + } + + public void setPropagateFailures(boolean propagateFailures) { + this.propagateFailures = propagateFailures; + } + + public boolean isPropagateStopFailures() { + return this.propagateStopFailures; + } + + public void setPropagateStopFailures(boolean propagateStopFailures) { + this.propagateStopFailures = propagateStopFailures; + } + + } + + public static class Producer { + + /** + * Name for the producer. If not assigned, a unique name is generated. + */ + private String name; + + /** + * Topic the producer will publish to. + */ + private String topicName; + + /** + * Time before a message has to be acknowledged by the broker. + */ + private Duration sendTimeout = Duration.ofSeconds(30); + + /** + * Message routing mode for a partitioned producer. + */ + private MessageRoutingMode messageRoutingMode = MessageRoutingMode.RoundRobinPartition; + + /** + * Message hashing scheme to choose the partition to which the message is + * published. + */ + private HashingScheme hashingScheme = HashingScheme.JavaStringHash; + + /** + * Whether to automatically batch messages. + */ + private boolean batchingEnabled = true; + + /** + * Whether to split large-size messages into multiple chunks. + */ + private boolean chunkingEnabled; + + /** + * Message compression type. + */ + private CompressionType compressionType; + + /** + * Type of access to the topic the producer requires. + */ + private ProducerAccessMode accessMode = ProducerAccessMode.Shared; + + private final Cache cache = new Cache(); + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + public String getTopicName() { + return this.topicName; + } + + public void setTopicName(String topicName) { + this.topicName = topicName; + } + + public Duration getSendTimeout() { + return this.sendTimeout; + } + + public void setSendTimeout(Duration sendTimeout) { + this.sendTimeout = sendTimeout; + } + + public MessageRoutingMode getMessageRoutingMode() { + return this.messageRoutingMode; + } + + public void setMessageRoutingMode(MessageRoutingMode messageRoutingMode) { + this.messageRoutingMode = messageRoutingMode; + } + + public HashingScheme getHashingScheme() { + return this.hashingScheme; + } + + public void setHashingScheme(HashingScheme hashingScheme) { + this.hashingScheme = hashingScheme; + } + + public boolean isBatchingEnabled() { + return this.batchingEnabled; + } + + public void setBatchingEnabled(boolean batchingEnabled) { + this.batchingEnabled = batchingEnabled; + } + + public boolean isChunkingEnabled() { + return this.chunkingEnabled; + } + + public void setChunkingEnabled(boolean chunkingEnabled) { + this.chunkingEnabled = chunkingEnabled; + } + + public CompressionType getCompressionType() { + return this.compressionType; + } + + public void setCompressionType(CompressionType compressionType) { + this.compressionType = compressionType; + } + + public ProducerAccessMode getAccessMode() { + return this.accessMode; + } + + public void setAccessMode(ProducerAccessMode accessMode) { + this.accessMode = accessMode; + } + + public Cache getCache() { + return this.cache; + } + + public static class Cache { + + /** + * Time period to expire unused entries in the cache. + */ + private Duration expireAfterAccess = Duration.ofMinutes(1); + + /** + * Maximum size of cache (entries). + */ + private long maximumSize = 1000L; + + /** + * Initial size of cache. + */ + private int initialCapacity = 50; + + public Duration getExpireAfterAccess() { + return this.expireAfterAccess; + } + + public void setExpireAfterAccess(Duration expireAfterAccess) { + this.expireAfterAccess = expireAfterAccess; + } + + public long getMaximumSize() { + return this.maximumSize; + } + + public void setMaximumSize(long maximumSize) { + this.maximumSize = maximumSize; + } + + public int getInitialCapacity() { + return this.initialCapacity; + } + + public void setInitialCapacity(int initialCapacity) { + this.initialCapacity = initialCapacity; + } + + } + + } + + public static class Consumer { + + /** + * Consumer name to identify a particular consumer from the topic stats. + */ + private String name; + + /** + * Topics the consumer subscribes to. + */ + private List topics; + + /** + * Pattern for topics the consumer subscribes to. + */ + private Pattern topicsPattern; + + /** + * Priority level for shared subscription consumers. + */ + private int priorityLevel = 0; + + /** + * Whether to read messages from the compacted topic rather than the full message + * backlog. + */ + private boolean readCompacted = false; + + /** + * Dead letter policy to use. + */ + @NestedConfigurationProperty + private DeadLetterPolicy deadLetterPolicy; + + /** + * Consumer subscription properties. + */ + private final Subscription subscription = new Subscription(); + + /** + * Whether to auto retry messages. + */ + private boolean retryEnable = false; + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + public Consumer.Subscription getSubscription() { + return this.subscription; + } + + public List getTopics() { + return this.topics; + } + + public void setTopics(List topics) { + this.topics = topics; + } + + public Pattern getTopicsPattern() { + return this.topicsPattern; + } + + public void setTopicsPattern(Pattern topicsPattern) { + this.topicsPattern = topicsPattern; + } + + public int getPriorityLevel() { + return this.priorityLevel; + } + + public void setPriorityLevel(int priorityLevel) { + this.priorityLevel = priorityLevel; + } + + public boolean isReadCompacted() { + return this.readCompacted; + } + + public void setReadCompacted(boolean readCompacted) { + this.readCompacted = readCompacted; + } + + public DeadLetterPolicy getDeadLetterPolicy() { + return this.deadLetterPolicy; + } + + public void setDeadLetterPolicy(DeadLetterPolicy deadLetterPolicy) { + this.deadLetterPolicy = deadLetterPolicy; + } + + public boolean isRetryEnable() { + return this.retryEnable; + } + + public void setRetryEnable(boolean retryEnable) { + this.retryEnable = retryEnable; + } + + public static class Subscription { + + /** + * Subscription name for the consumer. + */ + private String name; + + /** + * Position where to initialize a newly created subscription. + */ + private SubscriptionInitialPosition initialPosition = SubscriptionInitialPosition.Latest; + + /** + * Subscription mode to be used when subscribing to the topic. + */ + private SubscriptionMode mode = SubscriptionMode.Durable; + + /** + * Determines which type of topics (persistent, non-persistent, or all) the + * consumer should be subscribed to when using pattern subscriptions. + */ + private RegexSubscriptionMode topicsMode = RegexSubscriptionMode.PersistentOnly; + + /** + * Subscription type to be used when subscribing to a topic. + */ + private SubscriptionType type = SubscriptionType.Exclusive; + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + public SubscriptionInitialPosition getInitialPosition() { + return this.initialPosition; + } + + public void setInitialPosition(SubscriptionInitialPosition initialPosition) { + this.initialPosition = initialPosition; + } + + public SubscriptionMode getMode() { + return this.mode; + } + + public void setMode(SubscriptionMode mode) { + this.mode = mode; + } + + public RegexSubscriptionMode getTopicsMode() { + return this.topicsMode; + } + + public void setTopicsMode(RegexSubscriptionMode topicsMode) { + this.topicsMode = topicsMode; + } + + public SubscriptionType getType() { + return this.type; + } + + public void setType(SubscriptionType type) { + this.type = type; + } + + } + + public static class DeadLetterPolicy { + + /** + * Maximum number of times that a message will be redelivered before being + * sent to the dead letter queue. + */ + private int maxRedeliverCount; + + /** + * Name of the retry topic where the failing messages will be sent. + */ + private String retryLetterTopic; + + /** + * Name of the dead topic where the failing messages will be sent. + */ + private String deadLetterTopic; + + /** + * Name of the initial subscription of the dead letter topic. When not set, + * the initial subscription will not be created. However, when the property is + * set then the broker's 'allowAutoSubscriptionCreation' must be enabled or + * the DLQ producer will fail. + */ + private String initialSubscriptionName; + + public int getMaxRedeliverCount() { + return this.maxRedeliverCount; + } + + public void setMaxRedeliverCount(int maxRedeliverCount) { + this.maxRedeliverCount = maxRedeliverCount; + } + + public String getRetryLetterTopic() { + return this.retryLetterTopic; + } + + public void setRetryLetterTopic(String retryLetterTopic) { + this.retryLetterTopic = retryLetterTopic; + } + + public String getDeadLetterTopic() { + return this.deadLetterTopic; + } + + public void setDeadLetterTopic(String deadLetterTopic) { + this.deadLetterTopic = deadLetterTopic; + } + + public String getInitialSubscriptionName() { + return this.initialSubscriptionName; + } + + public void setInitialSubscriptionName(String initialSubscriptionName) { + this.initialSubscriptionName = initialSubscriptionName; + } + + } + + } + + public static class Listener { + + /** + * SchemaType of the consumed messages. + */ + private SchemaType schemaType; + + /** + * Whether to record observations for when the Observations API is available and + * the client supports it. + */ + private boolean observationEnabled = true; + + public SchemaType getSchemaType() { + return this.schemaType; + } + + public void setSchemaType(SchemaType schemaType) { + this.schemaType = schemaType; + } + + public boolean isObservationEnabled() { + return this.observationEnabled; + } + + public void setObservationEnabled(boolean observationEnabled) { + this.observationEnabled = observationEnabled; + } + + } + + public static class Reader { + + /** + * Reader name. + */ + private String name; + + /** + * Topis the reader subscribes to. + */ + private List topics; + + /** + * Subscription name. + */ + private String subscriptionName; + + /** + * Prefix of subscription role. + */ + private String subscriptionRolePrefix; + + /** + * Whether to read messages from a compacted topic rather than a full message + * backlog of a topic. + */ + private boolean readCompacted; + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + public List getTopics() { + return this.topics; + } + + public void setTopics(List topics) { + this.topics = topics; + } + + public String getSubscriptionName() { + return this.subscriptionName; + } + + public void setSubscriptionName(String subscriptionName) { + this.subscriptionName = subscriptionName; + } + + public String getSubscriptionRolePrefix() { + return this.subscriptionRolePrefix; + } + + public void setSubscriptionRolePrefix(String subscriptionRolePrefix) { + this.subscriptionRolePrefix = subscriptionRolePrefix; + } + + public boolean isReadCompacted() { + return this.readCompacted; + } + + public void setReadCompacted(boolean readCompacted) { + this.readCompacted = readCompacted; + } + + } + + public static class Template { + + /** + * Whether to record observations for when the Observations API is available. + */ + private boolean observationsEnabled = true; + + public boolean isObservationsEnabled() { + return this.observationsEnabled; + } + + public void setObservationsEnabled(boolean observationsEnabled) { + this.observationsEnabled = observationsEnabled; + } + + } + + public static class Authentication { + + /** + * Fully qualified class name of the authentication plugin. + */ + private String pluginClassName; + + /** + * Authentication parameter(s) as a map of parameter names to parameter values. + */ + private Map param = new LinkedHashMap<>(); + + public String getPluginClassName() { + return this.pluginClassName; + } + + public void setPluginClassName(String pluginClassName) { + this.pluginClassName = pluginClassName; + } + + public Map getParam() { + return this.param; + } + + public void setParam(Map param) { + this.param = param; + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarPropertiesMapper.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarPropertiesMapper.java new file mode 100644 index 0000000000..10c3a77597 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarPropertiesMapper.java @@ -0,0 +1,166 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.pulsar; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +import org.apache.pulsar.client.admin.PulsarAdminBuilder; +import org.apache.pulsar.client.api.ClientBuilder; +import org.apache.pulsar.client.api.ConsumerBuilder; +import org.apache.pulsar.client.api.ProducerBuilder; +import org.apache.pulsar.client.api.PulsarClientException.UnsupportedAuthenticationException; +import org.apache.pulsar.client.api.ReaderBuilder; + +import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.pulsar.listener.PulsarContainerProperties; +import org.springframework.pulsar.reader.PulsarReaderContainerProperties; +import org.springframework.util.StringUtils; + +/** + * Helper class used to map {@link PulsarProperties} to various builder customizers. + * + * @author Chris Bono + * @author Phillip Webb + */ +final class PulsarPropertiesMapper { + + private final PulsarProperties properties; + + PulsarPropertiesMapper(PulsarProperties properties) { + this.properties = properties; + } + + void customizeClientBuilder(ClientBuilder clientBuilder) { + PulsarProperties.Client properties = this.properties.getClient(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(properties::getServiceUrl).to(clientBuilder::serviceUrl); + map.from(properties::getConnectionTimeout).to(timeoutProperty(clientBuilder::connectionTimeout)); + map.from(properties::getOperationTimeout).to(timeoutProperty(clientBuilder::operationTimeout)); + map.from(properties::getLookupTimeout).to(timeoutProperty(clientBuilder::lookupTimeout)); + customizeAuthentication(clientBuilder::authentication, properties.getAuthentication()); + } + + void customizeAdminBuilder(PulsarAdminBuilder adminBuilder) { + PulsarProperties.Admin properties = this.properties.getAdmin(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(properties::getServiceUrl).to(adminBuilder::serviceHttpUrl); + map.from(properties::getConnectionTimeout).to(timeoutProperty(adminBuilder::connectionTimeout)); + map.from(properties::getReadTimeout).to(timeoutProperty(adminBuilder::readTimeout)); + map.from(properties::getRequestTimeout).to(timeoutProperty(adminBuilder::requestTimeout)); + customizeAuthentication(adminBuilder::authentication, properties.getAuthentication()); + } + + private void customizeAuthentication(AuthenticationConsumer authentication, + PulsarProperties.Authentication properties) { + if (StringUtils.hasText(properties.getPluginClassName())) { + try { + authentication.accept(properties.getPluginClassName(), properties.getParam()); + } + catch (UnsupportedAuthenticationException ex) { + throw new IllegalStateException("Unable to configure Pulsar authentication", ex); + } + } + } + + void customizeProducerBuilder(ProducerBuilder producerBuilder) { + PulsarProperties.Producer properties = this.properties.getProducer(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(properties::getName).to(producerBuilder::producerName); + map.from(properties::getTopicName).to(producerBuilder::topic); + map.from(properties::getSendTimeout).to(timeoutProperty(producerBuilder::sendTimeout)); + map.from(properties::getMessageRoutingMode).to(producerBuilder::messageRoutingMode); + map.from(properties::getHashingScheme).to(producerBuilder::hashingScheme); + map.from(properties::isBatchingEnabled).to(producerBuilder::enableBatching); + map.from(properties::isChunkingEnabled).to(producerBuilder::enableChunking); + map.from(properties::getCompressionType).to(producerBuilder::compressionType); + map.from(properties::getAccessMode).to(producerBuilder::accessMode); + } + + void customizeConsumerBuilder(ConsumerBuilder consumerBuilder) { + PulsarProperties.Consumer properties = this.properties.getConsumer(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(properties::getName).to(consumerBuilder::consumerName); + map.from(properties::getTopics).as(ArrayList::new).to(consumerBuilder::topics); + map.from(properties::getTopicsPattern).to(consumerBuilder::topicsPattern); + map.from(properties::getPriorityLevel).to(consumerBuilder::priorityLevel); + map.from(properties::isReadCompacted).to(consumerBuilder::readCompacted); + map.from(properties::getDeadLetterPolicy).as(DeadLetterPolicyMapper::map).to(consumerBuilder::deadLetterPolicy); + map.from(properties::isRetryEnable).to(consumerBuilder::enableRetry); + customizeConsumerBuilderSubscription(consumerBuilder); + } + + private void customizeConsumerBuilderSubscription(ConsumerBuilder consumerBuilder) { + PulsarProperties.Consumer.Subscription properties = this.properties.getConsumer().getSubscription(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(properties::getName).to(consumerBuilder::subscriptionName); + map.from(properties::getInitialPosition).to(consumerBuilder::subscriptionInitialPosition); + map.from(properties::getMode).to(consumerBuilder::subscriptionMode); + map.from(properties::getTopicsMode).to(consumerBuilder::subscriptionTopicsMode); + map.from(properties::getType).to(consumerBuilder::subscriptionType); + } + + void customizeContainerProperties(PulsarContainerProperties containerProperties) { + customizePulsarContainerConsumerSubscriptionProperties(containerProperties); + customizePulsarContainerListenerProperties(containerProperties); + } + + private void customizePulsarContainerConsumerSubscriptionProperties(PulsarContainerProperties containerProperties) { + PulsarProperties.Consumer.Subscription properties = this.properties.getConsumer().getSubscription(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(properties::getType).to(containerProperties::setSubscriptionType); + } + + private void customizePulsarContainerListenerProperties(PulsarContainerProperties containerProperties) { + PulsarProperties.Listener properties = this.properties.getListener(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(properties::getSchemaType).to(containerProperties::setSchemaType); + map.from(properties::isObservationEnabled).to(containerProperties::setObservationEnabled); + } + + void customizeReaderBuilder(ReaderBuilder readerBuilder) { + PulsarProperties.Reader properties = this.properties.getReader(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(properties::getName).to(readerBuilder::readerName); + map.from(properties::getTopics).to(readerBuilder::topics); + map.from(properties::getSubscriptionName).to(readerBuilder::subscriptionName); + map.from(properties::getSubscriptionRolePrefix).to(readerBuilder::subscriptionRolePrefix); + map.from(properties::isReadCompacted).to(readerBuilder::readCompacted); + } + + void customizeReaderContainerProperties(PulsarReaderContainerProperties readerContainerProperties) { + PulsarProperties.Reader properties = this.properties.getReader(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(properties::getTopics).to(readerContainerProperties::setTopics); + } + + private Consumer timeoutProperty(BiConsumer setter) { + return (duration) -> setter.accept((int) duration.toMillis(), TimeUnit.MILLISECONDS); + } + + private interface AuthenticationConsumer { + + void accept(String authPluginClassName, Map authParams) + throws UnsupportedAuthenticationException; + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarReactiveAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarReactiveAutoConfiguration.java new file mode 100644 index 0000000000..4c2aeb172d --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarReactiveAutoConfiguration.java @@ -0,0 +1,201 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.pulsar; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +import org.apache.pulsar.client.api.PulsarClient; +import org.apache.pulsar.reactive.client.adapter.AdaptedReactivePulsarClientFactory; +import org.apache.pulsar.reactive.client.adapter.ProducerCacheProvider; +import org.apache.pulsar.reactive.client.api.ReactiveMessageConsumerBuilder; +import org.apache.pulsar.reactive.client.api.ReactiveMessageReaderBuilder; +import org.apache.pulsar.reactive.client.api.ReactiveMessageSenderBuilder; +import org.apache.pulsar.reactive.client.api.ReactiveMessageSenderCache; +import org.apache.pulsar.reactive.client.api.ReactivePulsarClient; +import org.apache.pulsar.reactive.client.producercache.CaffeineShadedProducerCacheProvider; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.util.LambdaSafe; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.pulsar.config.PulsarAnnotationSupportBeanNames; +import org.springframework.pulsar.core.SchemaResolver; +import org.springframework.pulsar.core.TopicResolver; +import org.springframework.pulsar.reactive.config.DefaultReactivePulsarListenerContainerFactory; +import org.springframework.pulsar.reactive.config.annotation.EnableReactivePulsar; +import org.springframework.pulsar.reactive.core.DefaultReactivePulsarConsumerFactory; +import org.springframework.pulsar.reactive.core.DefaultReactivePulsarReaderFactory; +import org.springframework.pulsar.reactive.core.DefaultReactivePulsarSenderFactory; +import org.springframework.pulsar.reactive.core.ReactiveMessageConsumerBuilderCustomizer; +import org.springframework.pulsar.reactive.core.ReactiveMessageReaderBuilderCustomizer; +import org.springframework.pulsar.reactive.core.ReactiveMessageSenderBuilderCustomizer; +import org.springframework.pulsar.reactive.core.ReactivePulsarConsumerFactory; +import org.springframework.pulsar.reactive.core.ReactivePulsarReaderFactory; +import org.springframework.pulsar.reactive.core.ReactivePulsarSenderFactory; +import org.springframework.pulsar.reactive.core.ReactivePulsarTemplate; +import org.springframework.pulsar.reactive.listener.ReactivePulsarContainerProperties; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for Spring for Apache Pulsar + * Reactive. + * + * @author Chris Bono + * @author Christophe Bornet + * @since 3.2.0 + */ +@AutoConfiguration(after = PulsarAutoConfiguration.class) +@ConditionalOnClass({ PulsarClient.class, ReactivePulsarClient.class, ReactivePulsarTemplate.class }) +@Import(PulsarConfiguration.class) +public class PulsarReactiveAutoConfiguration { + + private final PulsarProperties properties; + + private final PulsarReactivePropertiesMapper propertiesMapper; + + PulsarReactiveAutoConfiguration(PulsarProperties properties) { + this.properties = properties; + this.propertiesMapper = new PulsarReactivePropertiesMapper(properties); + } + + @Bean + @ConditionalOnMissingBean + ReactivePulsarClient reactivePulsarClient(PulsarClient pulsarClient) { + return AdaptedReactivePulsarClientFactory.create(pulsarClient); + } + + @Bean + @ConditionalOnMissingBean(ProducerCacheProvider.class) + @ConditionalOnClass(CaffeineShadedProducerCacheProvider.class) + @ConditionalOnProperty(name = "spring.pulsar.producer.cache.enabled", havingValue = "true", matchIfMissing = true) + CaffeineShadedProducerCacheProvider reactivePulsarProducerCacheProvider() { + PulsarProperties.Producer.Cache properties = this.properties.getProducer().getCache(); + return new CaffeineShadedProducerCacheProvider(properties.getExpireAfterAccess(), Duration.ofMinutes(10), + properties.getMaximumSize(), properties.getInitialCapacity()); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(name = "spring.pulsar.producer.cache.enabled", havingValue = "true", matchIfMissing = true) + ReactiveMessageSenderCache reactivePulsarMessageSenderCache( + ObjectProvider producerCacheProvider) { + return reactivePulsarMessageSenderCache(producerCacheProvider.getIfAvailable()); + } + + private ReactiveMessageSenderCache reactivePulsarMessageSenderCache(ProducerCacheProvider producerCacheProvider) { + return (producerCacheProvider != null) ? AdaptedReactivePulsarClientFactory.createCache(producerCacheProvider) + : AdaptedReactivePulsarClientFactory.createCache(); + } + + @Bean + @ConditionalOnMissingBean(ReactivePulsarSenderFactory.class) + DefaultReactivePulsarSenderFactory reactivePulsarSenderFactory(ReactivePulsarClient reactivePulsarClient, + ObjectProvider reactiveMessageSenderCache, TopicResolver topicResolver, + ObjectProvider> customizersProvider) { + List> customizers = new ArrayList<>(); + customizers.add(this.propertiesMapper::customizeMessageSenderBuilder); + customizers.addAll(customizersProvider.orderedStream().toList()); + List> lambdaSafeCustomizers = List + .of((builder) -> applyMessageSenderBuilderCustomizers(customizers, builder)); + return DefaultReactivePulsarSenderFactory.builderFor(reactivePulsarClient) + .withDefaultConfigCustomizers(lambdaSafeCustomizers) + .withMessageSenderCache(reactiveMessageSenderCache.getIfAvailable()) + .withTopicResolver(topicResolver) + .build(); + } + + @SuppressWarnings("unchecked") + private void applyMessageSenderBuilderCustomizers(List> customizers, + ReactiveMessageSenderBuilder builder) { + LambdaSafe.callbacks(ReactiveMessageSenderBuilderCustomizer.class, customizers, builder) + .invoke((customizer) -> customizer.customize(builder)); + } + + @Bean + @ConditionalOnMissingBean(ReactivePulsarConsumerFactory.class) + DefaultReactivePulsarConsumerFactory reactivePulsarConsumerFactory( + ReactivePulsarClient pulsarReactivePulsarClient, + ObjectProvider> customizersProvider) { + List> customizers = new ArrayList<>(); + customizers.add(this.propertiesMapper::customizeMessageConsumerBuilder); + customizers.addAll(customizersProvider.orderedStream().toList()); + List> lambdaSafeCustomizers = List + .of((builder) -> applyMessageConsumerBuilderCustomizers(customizers, builder)); + return new DefaultReactivePulsarConsumerFactory<>(pulsarReactivePulsarClient, lambdaSafeCustomizers); + } + + @SuppressWarnings("unchecked") + private void applyMessageConsumerBuilderCustomizers(List> customizers, + ReactiveMessageConsumerBuilder builder) { + LambdaSafe.callbacks(ReactiveMessageConsumerBuilderCustomizer.class, customizers, builder) + .invoke((customizer) -> customizer.customize(builder)); + } + + @Bean + @ConditionalOnMissingBean(name = "reactivePulsarListenerContainerFactory") + DefaultReactivePulsarListenerContainerFactory reactivePulsarListenerContainerFactory( + ReactivePulsarConsumerFactory reactivePulsarConsumerFactory, SchemaResolver schemaResolver, + TopicResolver topicResolver) { + ReactivePulsarContainerProperties containerProperties = new ReactivePulsarContainerProperties<>(); + containerProperties.setSchemaResolver(schemaResolver); + containerProperties.setTopicResolver(topicResolver); + this.propertiesMapper.customizeContainerProperties(containerProperties); + return new DefaultReactivePulsarListenerContainerFactory<>(reactivePulsarConsumerFactory, containerProperties); + } + + @Bean + @ConditionalOnMissingBean(ReactivePulsarReaderFactory.class) + DefaultReactivePulsarReaderFactory reactivePulsarReaderFactory(ReactivePulsarClient reactivePulsarClient, + ObjectProvider> customizersProvider) { + List> customizers = new ArrayList<>(); + customizers.add(this.propertiesMapper::customizeMessageReaderBuilder); + customizers.addAll(customizersProvider.orderedStream().toList()); + List> lambdaSafeCustomizers = List + .of((builder) -> applyMessageReaderBuilderCustomizers(customizers, builder)); + return new DefaultReactivePulsarReaderFactory<>(reactivePulsarClient, lambdaSafeCustomizers); + } + + @SuppressWarnings("unchecked") + private void applyMessageReaderBuilderCustomizers(List> customizers, + ReactiveMessageReaderBuilder builder) { + LambdaSafe.callbacks(ReactiveMessageReaderBuilderCustomizer.class, customizers, builder) + .invoke((customizer) -> customizer.customize(builder)); + } + + @Bean + @ConditionalOnMissingBean + ReactivePulsarTemplate pulsarReactiveTemplate(ReactivePulsarSenderFactory reactivePulsarSenderFactory, + SchemaResolver schemaResolver, TopicResolver topicResolver) { + return new ReactivePulsarTemplate<>(reactivePulsarSenderFactory, schemaResolver, topicResolver); + } + + @Configuration(proxyBeanMethods = false) + @EnableReactivePulsar + @ConditionalOnMissingBean( + name = PulsarAnnotationSupportBeanNames.REACTIVE_PULSAR_LISTENER_ANNOTATION_PROCESSOR_BEAN_NAME) + static class EnableReactivePulsarConfiguration { + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarReactivePropertiesMapper.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarReactivePropertiesMapper.java new file mode 100644 index 0000000000..2f79bbae61 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/PulsarReactivePropertiesMapper.java @@ -0,0 +1,108 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.pulsar; + +import java.util.ArrayList; + +import org.apache.pulsar.reactive.client.api.ReactiveMessageConsumerBuilder; +import org.apache.pulsar.reactive.client.api.ReactiveMessageReaderBuilder; +import org.apache.pulsar.reactive.client.api.ReactiveMessageSenderBuilder; + +import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.pulsar.reactive.listener.ReactivePulsarContainerProperties; + +/** + * Helper class used to map reactive {@link PulsarProperties} to various builder + * customizers. + * + * @author Chris Bono + * @author Phillip Webb + */ +final class PulsarReactivePropertiesMapper { + + private final PulsarProperties properties; + + PulsarReactivePropertiesMapper(PulsarProperties properties) { + this.properties = properties; + } + + void customizeMessageSenderBuilder(ReactiveMessageSenderBuilder builder) { + PulsarProperties.Producer properties = this.properties.getProducer(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(properties::getName).to(builder::producerName); + map.from(properties::getTopicName).to(builder::topic); + map.from(properties::getSendTimeout).to(builder::sendTimeout); + map.from(properties::getMessageRoutingMode).to(builder::messageRoutingMode); + map.from(properties::getHashingScheme).to(builder::hashingScheme); + map.from(properties::isBatchingEnabled).to(builder::batchingEnabled); + map.from(properties::isChunkingEnabled).to(builder::chunkingEnabled); + map.from(properties::getCompressionType).to(builder::compressionType); + map.from(properties::getAccessMode).to(builder::accessMode); + } + + void customizeMessageConsumerBuilder(ReactiveMessageConsumerBuilder builder) { + PulsarProperties.Consumer properties = this.properties.getConsumer(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(properties::getName).to(builder::consumerName); + map.from(properties::getTopics).as(ArrayList::new).to(builder::topics); + map.from(properties::getTopicsPattern).to(builder::topicsPattern); + map.from(properties::getPriorityLevel).to(builder::priorityLevel); + map.from(properties::isReadCompacted).to(builder::readCompacted); + map.from(properties::getDeadLetterPolicy).as(DeadLetterPolicyMapper::map).to(builder::deadLetterPolicy); + map.from(properties::isRetryEnable).to(builder::retryLetterTopicEnable); + customizerMessageConsumerBuilderSubscription(builder); + } + + private void customizerMessageConsumerBuilderSubscription(ReactiveMessageConsumerBuilder builder) { + PulsarProperties.Consumer.Subscription properties = this.properties.getConsumer().getSubscription(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(properties::getName).to(builder::subscriptionName); + map.from(properties::getInitialPosition).to(builder::subscriptionInitialPosition); + map.from(properties::getMode).to(builder::subscriptionMode); + map.from(properties::getTopicsMode).to(builder::topicsPatternSubscriptionMode); + map.from(properties::getType).to(builder::subscriptionType); + } + + void customizeContainerProperties(ReactivePulsarContainerProperties containerProperties) { + customizePulsarContainerConsumerSubscriptionProperties(containerProperties); + customizePulsarContainerListenerProperties(containerProperties); + } + + private void customizePulsarContainerConsumerSubscriptionProperties( + ReactivePulsarContainerProperties containerProperties) { + PulsarProperties.Consumer.Subscription properties = this.properties.getConsumer().getSubscription(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(properties::getType).to(containerProperties::setSubscriptionType); + } + + private void customizePulsarContainerListenerProperties(ReactivePulsarContainerProperties containerProperties) { + PulsarProperties.Listener properties = this.properties.getListener(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(properties::getSchemaType).to(containerProperties::setSchemaType); + } + + void customizeMessageReaderBuilder(ReactiveMessageReaderBuilder builder) { + PulsarProperties.Reader properties = this.properties.getReader(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(properties::getName).to(builder::readerName); + map.from(properties::getTopics).to(builder::topics); + map.from(properties::getSubscriptionName).to(builder::subscriptionName); + map.from(properties::getSubscriptionRolePrefix).to(builder::generatedSubscriptionNamePrefix); + map.from(properties::isReadCompacted).to(builder::readCompacted); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/package-info.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/package-info.java new file mode 100644 index 0000000000..d6ce8ee1d2 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/pulsar/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * 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 + * + * https://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. + */ + +/** + * Auto-configuration for Spring for Apache Pulsar. + */ +package org.springframework.boot.autoconfigure.pulsar; diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 8bfa50b648..db3b6221d0 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -2003,6 +2003,18 @@ "name": "spring.neo4j.uri", "defaultValue": "bolt://localhost:7687" }, + { + "name": "spring.pulsar.function.enabled", + "type": "java.lang.Boolean", + "description": "Whether to enable function support.", + "defaultValue": true + }, + { + "name": "spring.pulsar.producer.cache.enabled", + "type": "java.lang.Boolean", + "description": "Whether to enable caching in the PulsarProducerFactory.", + "defaultValue": true + }, { "name": "spring.quartz.jdbc.comment-prefix", "defaultValue": [ diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 972dad6815..7f6c606cd0 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -94,6 +94,8 @@ org.springframework.boot.autoconfigure.mustache.MustacheAutoConfiguration org.springframework.boot.autoconfigure.neo4j.Neo4jAutoConfiguration org.springframework.boot.autoconfigure.netty.NettyAutoConfiguration org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration +org.springframework.boot.autoconfigure.pulsar.PulsarAutoConfiguration +org.springframework.boot.autoconfigure.pulsar.PulsarReactiveAutoConfiguration org.springframework.boot.autoconfigure.quartz.QuartzAutoConfiguration org.springframework.boot.autoconfigure.r2dbc.R2dbcAutoConfiguration org.springframework.boot.autoconfigure.r2dbc.R2dbcTransactionManagerAutoConfiguration diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/Customizers.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/Customizers.java new file mode 100644 index 0000000000..7f8de35561 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/Customizers.java @@ -0,0 +1,106 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.pulsar; + +import java.util.List; +import java.util.function.BiConsumer; + +import org.assertj.core.api.AssertDelegateTarget; +import org.mockito.InOrder; + +import org.springframework.test.util.ReflectionTestUtils; + +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; + +/** + * Test utility used to check customizers are called correctly. + * + * @param the customizer type + * @param the target class that is customized + * @author Phillip Webb + * @author Chris Bono + */ +final class Customizers { + + private final BiConsumer customizeAction; + + private final Class targetClass; + + @SuppressWarnings("unchecked") + private Customizers(Class targetClass, BiConsumer customizeAction) { + this.customizeAction = customizeAction; + this.targetClass = (Class) targetClass; + } + + /** + * Create an instance by getting the value from a field. + * @param source the source to extract the customizers from + * @param fieldName the field name + * @return a new {@link CustomizersAssert} instance + */ + @SuppressWarnings("unchecked") + CustomizersAssert fromField(Object source, String fieldName) { + return new CustomizersAssert(ReflectionTestUtils.getField(source, fieldName)); + } + + /** + * Create a new {@link Customizers} instance. + * @param the customizer class + * @param the target class that is customized + * @param targetClass the target class that is customized + * @param customizeAction the customizer action to take + * @return a new {@link Customizers} instance + */ + static Customizers of(Class targetClass, BiConsumer customizeAction) { + return new Customizers<>(targetClass, customizeAction); + } + + /** + * Assertions that can be applied to customizers. + */ + final class CustomizersAssert implements AssertDelegateTarget { + + private final List customizers; + + @SuppressWarnings("unchecked") + private CustomizersAssert(Object customizers) { + this.customizers = (customizers instanceof List) ? (List) customizers : List.of((C) customizers); + } + + /** + * Assert that the customize method is called in a specified order. It is expected + * that each customizer has set a unique value so the expected values can be used + * as a verify step. + * @param the value type + * @param call the call the customizer makes + * @param expectedValues the expected values + */ + @SuppressWarnings("unchecked") + void callsInOrder(BiConsumer call, V... expectedValues) { + T target = mock(Customizers.this.targetClass); + BiConsumer customizeAction = Customizers.this.customizeAction; + this.customizers.forEach((customizer) -> customizeAction.accept(customizer, target)); + InOrder ordered = inOrder(target); + for (V expectedValue : expectedValues) { + call.accept(ordered.verify(target), expectedValue); + } + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/DeadLetterPolicyMapperTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/DeadLetterPolicyMapperTests.java new file mode 100644 index 0000000000..afc18050f6 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/DeadLetterPolicyMapperTests.java @@ -0,0 +1,55 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.pulsar; + +import org.apache.pulsar.client.api.DeadLetterPolicy; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * Tests for {@link DeadLetterPolicyMapper}. + * + * @author Chris Bono + * @author Phillip Webb + */ +class DeadLetterPolicyMapperTests { + + @Test + void map() { + PulsarProperties.Consumer.DeadLetterPolicy properties = new PulsarProperties.Consumer.DeadLetterPolicy(); + properties.setMaxRedeliverCount(100); + properties.setRetryLetterTopic("my-retry-topic"); + properties.setDeadLetterTopic("my-dlt-topic"); + properties.setInitialSubscriptionName("my-initial-subscription"); + DeadLetterPolicy policy = DeadLetterPolicyMapper.map(properties); + assertThat(policy.getMaxRedeliverCount()).isEqualTo(100); + assertThat(policy.getRetryLetterTopic()).isEqualTo("my-retry-topic"); + assertThat(policy.getDeadLetterTopic()).isEqualTo("my-dlt-topic"); + assertThat(policy.getInitialSubscriptionName()).isEqualTo("my-initial-subscription"); + } + + @Test + void mapWhenMaxRedeliverCountIsNotPositiveThrowsException() { + PulsarProperties.Consumer.DeadLetterPolicy properties = new PulsarProperties.Consumer.DeadLetterPolicy(); + properties.setMaxRedeliverCount(0); + assertThatIllegalStateException().isThrownBy(() -> DeadLetterPolicyMapper.map(properties)) + .withMessage("Pulsar DeadLetterPolicy must have a positive 'max-redelivery-count' property value"); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarAutoConfigurationIntegrationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarAutoConfigurationIntegrationTests.java new file mode 100644 index 0000000000..14c7a37baf --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarAutoConfigurationIntegrationTests.java @@ -0,0 +1,118 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.pulsar; + +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.apache.pulsar.client.api.PulsarClientException; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.PulsarContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.ServletWebServerFactoryAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.testsupport.testcontainers.DockerImageNames; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.pulsar.annotation.PulsarListener; +import org.springframework.pulsar.core.PulsarTemplate; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link PulsarAutoConfiguration}. + * + * @author Chris Bono + * @author Phillip Webb + */ +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@Testcontainers(disabledWithoutDocker = true) +class PulsarAutoConfigurationIntegrationTests { + + @Container + private static final PulsarContainer PULSAR_CONTAINER = new PulsarContainer(DockerImageNames.pulsar()) + .withStartupAttempts(2) + .withStartupTimeout(Duration.ofMinutes(3)); + + private static final CountDownLatch LISTEN_LATCH = new CountDownLatch(1); + + private static final String TOPIC = "pacit-hello-topic"; + + @DynamicPropertySource + static void pulsarProperties(DynamicPropertyRegistry registry) { + registry.add("spring.pulsar.client.service-url", PULSAR_CONTAINER::getPulsarBrokerUrl); + registry.add("spring.pulsar.admin.service-url", PULSAR_CONTAINER::getHttpServiceUrl); + } + + @Test + void appStartsWithAutoConfiguredSpringPulsarComponents( + @Autowired(required = false) PulsarTemplate pulsarTemplate) { + assertThat(pulsarTemplate).isNotNull(); + } + + @Test + void templateCanBeAccessedDuringWebRequest(@Autowired TestRestTemplate restTemplate) throws InterruptedException { + assertThat(restTemplate.getForObject("/hello", String.class)).startsWith("Hello World -> "); + assertThat(LISTEN_LATCH.await(5, TimeUnit.SECONDS)).isTrue(); + } + + @Configuration(proxyBeanMethods = false) + @ImportAutoConfiguration({ DispatcherServletAutoConfiguration.class, ServletWebServerFactoryAutoConfiguration.class, + WebMvcAutoConfiguration.class, HttpMessageConvertersAutoConfiguration.class, JacksonAutoConfiguration.class, + PulsarAutoConfiguration.class, PulsarReactiveAutoConfiguration.class }) + @Import(TestWebController.class) + static class TestConfiguration { + + @PulsarListener(subscriptionName = TOPIC + "-sub", topics = TOPIC) + void listen(String ignored) { + LISTEN_LATCH.countDown(); + } + + } + + @RestController + static class TestWebController { + + private final PulsarTemplate pulsarTemplate; + + TestWebController(PulsarTemplate pulsarTemplate) { + this.pulsarTemplate = pulsarTemplate; + } + + @GetMapping("/hello") + String sayHello() throws PulsarClientException { + return "Hello World -> " + this.pulsarTemplate.send(TOPIC, "hello"); + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarAutoConfigurationTests.java new file mode 100644 index 0000000000..3710c9313c --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarAutoConfigurationTests.java @@ -0,0 +1,519 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.pulsar; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import com.github.benmanes.caffeine.cache.Caffeine; +import org.apache.pulsar.client.api.ConsumerBuilder; +import org.apache.pulsar.client.api.ProducerBuilder; +import org.apache.pulsar.client.api.PulsarClient; +import org.apache.pulsar.client.api.ReaderBuilder; +import org.apache.pulsar.client.api.interceptor.ProducerInterceptor; +import org.apache.pulsar.common.schema.SchemaType; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.pulsar.annotation.PulsarBootstrapConfiguration; +import org.springframework.pulsar.annotation.PulsarListenerAnnotationBeanPostProcessor; +import org.springframework.pulsar.annotation.PulsarReaderAnnotationBeanPostProcessor; +import org.springframework.pulsar.config.ConcurrentPulsarListenerContainerFactory; +import org.springframework.pulsar.config.DefaultPulsarReaderContainerFactory; +import org.springframework.pulsar.config.PulsarListenerContainerFactory; +import org.springframework.pulsar.config.PulsarListenerEndpointRegistry; +import org.springframework.pulsar.config.PulsarReaderEndpointRegistry; +import org.springframework.pulsar.core.CachingPulsarProducerFactory; +import org.springframework.pulsar.core.ConsumerBuilderCustomizer; +import org.springframework.pulsar.core.DefaultPulsarClientFactory; +import org.springframework.pulsar.core.DefaultPulsarConsumerFactory; +import org.springframework.pulsar.core.DefaultPulsarProducerFactory; +import org.springframework.pulsar.core.DefaultPulsarReaderFactory; +import org.springframework.pulsar.core.DefaultSchemaResolver; +import org.springframework.pulsar.core.DefaultTopicResolver; +import org.springframework.pulsar.core.ProducerBuilderCustomizer; +import org.springframework.pulsar.core.PulsarAdministration; +import org.springframework.pulsar.core.PulsarConsumerFactory; +import org.springframework.pulsar.core.PulsarProducerFactory; +import org.springframework.pulsar.core.PulsarReaderFactory; +import org.springframework.pulsar.core.PulsarTemplate; +import org.springframework.pulsar.core.ReaderBuilderCustomizer; +import org.springframework.pulsar.core.SchemaResolver; +import org.springframework.pulsar.core.TopicResolver; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link PulsarAutoConfiguration}. + * + * @author Chris Bono + * @author Alexander Preuß + * @author Soby Chacko + * @author Phillip Webb + */ +class PulsarAutoConfigurationTests { + + private static final String INTERNAL_PULSAR_LISTENER_ANNOTATION_PROCESSOR = "org.springframework.pulsar.config.internalPulsarListenerAnnotationProcessor"; + + private static final String INTERNAL_PULSAR_READER_ANNOTATION_PROCESSOR = "org.springframework.pulsar.config.internalPulsarReaderAnnotationProcessor"; + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(PulsarAutoConfiguration.class)) + .withBean(PulsarClient.class, () -> mock(PulsarClient.class)); + + @Test + void whenPulsarNotOnClasspathAutoConfigurationIsSkipped() { + new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(PulsarAutoConfiguration.class)) + .withClassLoader(new FilteredClassLoader(PulsarClient.class)) + .run((context) -> assertThat(context).doesNotHaveBean(PulsarAutoConfiguration.class)); + } + + @Test + void whenSpringPulsarNotOnClasspathAutoConfigurationIsSkipped() { + this.contextRunner.withClassLoader(new FilteredClassLoader(PulsarTemplate.class)) + .run((context) -> assertThat(context).doesNotHaveBean(PulsarAutoConfiguration.class)); + } + + @Test + void whenCustomPulsarListenerAnnotationProcessorDefinedAutoConfigurationIsSkipped() { + this.contextRunner.withBean(INTERNAL_PULSAR_LISTENER_ANNOTATION_PROCESSOR, String.class, () -> "bean") + .run((context) -> assertThat(context).doesNotHaveBean(PulsarBootstrapConfiguration.class)); + } + + @Test + void whenCustomPulsarReaderAnnotationProcessorDefinedAutoConfigurationIsSkipped() { + this.contextRunner.withBean(INTERNAL_PULSAR_READER_ANNOTATION_PROCESSOR, String.class, () -> "bean") + .run((context) -> assertThat(context).doesNotHaveBean(PulsarBootstrapConfiguration.class)); + } + + @Test + void autoConfiguresBeans() { + this.contextRunner.run((context) -> assertThat(context).hasSingleBean(PulsarConfiguration.class) + .hasSingleBean(DefaultPulsarClientFactory.class) + .hasSingleBean(PulsarClient.class) + .hasSingleBean(PulsarAdministration.class) + .hasSingleBean(DefaultSchemaResolver.class) + .hasSingleBean(DefaultTopicResolver.class) + .hasSingleBean(CachingPulsarProducerFactory.class) + .hasSingleBean(PulsarTemplate.class) + .hasSingleBean(DefaultPulsarConsumerFactory.class) + .hasSingleBean(ConcurrentPulsarListenerContainerFactory.class) + .hasSingleBean(DefaultPulsarReaderFactory.class) + .hasSingleBean(DefaultPulsarReaderContainerFactory.class) + .hasSingleBean(PulsarListenerAnnotationBeanPostProcessor.class) + .hasSingleBean(PulsarListenerEndpointRegistry.class) + .hasSingleBean(PulsarReaderAnnotationBeanPostProcessor.class) + .hasSingleBean(PulsarReaderEndpointRegistry.class)); + } + + @Nested + class ProducerFactoryTests { + + private final ApplicationContextRunner contextRunner = PulsarAutoConfigurationTests.this.contextRunner; + + @Test + @SuppressWarnings("unchecked") + void whenHasUserDefinedBeanDoesNotAutoConfigureBean() { + PulsarProducerFactory producerFactory = mock(PulsarProducerFactory.class); + this.contextRunner + .withBean("customPulsarProducerFactory", PulsarProducerFactory.class, () -> producerFactory) + .run((context) -> assertThat(context).getBean(PulsarProducerFactory.class).isSameAs(producerFactory)); + } + + @Test + void whenNoPropertiesUsesCachingPulsarProducerFactory() { + this.contextRunner.run((context) -> assertThat(context).getBean(PulsarProducerFactory.class) + .isExactlyInstanceOf(CachingPulsarProducerFactory.class)); + } + + @Test + void whenCachingDisabledUsesDefaultPulsarProducerFactory() { + this.contextRunner.withPropertyValues("spring.pulsar.producer.cache.enabled=false") + .run((context) -> assertThat(context).getBean(PulsarProducerFactory.class) + .isExactlyInstanceOf(DefaultPulsarProducerFactory.class)); + } + + @Test + void whenCachingEnabledUsesCachingPulsarProducerFactory() { + this.contextRunner.withPropertyValues("spring.pulsar.producer.cache.enabled=true") + .run((context) -> assertThat(context).getBean(PulsarProducerFactory.class) + .isExactlyInstanceOf(CachingPulsarProducerFactory.class)); + } + + @Test + void whenCachingEnabledAndCaffeineNotOnClasspathStillUsesCaffeine() { + this.contextRunner.withClassLoader(new FilteredClassLoader(Caffeine.class)) + .withPropertyValues("spring.pulsar.producer.cache.enabled=true") + .run((context) -> { + assertThat(context).getBean(CachingPulsarProducerFactory.class) + .extracting("producerCache") + .extracting(Object::getClass) + .extracting(Class::getName) + .isEqualTo("org.springframework.pulsar.core.CaffeineCacheProvider"); + assertThat(context).getBean(CachingPulsarProducerFactory.class) + .extracting("producerCache.cache") + .extracting(Object::getClass) + .extracting(Class::getName) + .asString() + .startsWith("org.springframework.pulsar.shade.com.github.benmanes.caffeine.cache."); + }); + } + + @Test + void whenCustomCachingPropertiesCreatesConfiguredBean() { + this.contextRunner + .withPropertyValues("spring.pulsar.producer.cache.expire-after-access=100s", + "spring.pulsar.producer.cache.maximum-size=5150", + "spring.pulsar.producer.cache.initial-capacity=200") + .run((context) -> assertThat(context).getBean(CachingPulsarProducerFactory.class) + .extracting("producerCache.cache.cache") + .hasFieldOrPropertyWithValue("maximum", 5150L) + .hasFieldOrPropertyWithValue("expiresAfterAccessNanos", TimeUnit.SECONDS.toNanos(100))); + } + + @Test + void whenHasTopicNamePropertyCreatesConfiguredBean() { + this.contextRunner.withPropertyValues("spring.pulsar.producer.topic-name=my-topic") + .run((context) -> assertThat(context).getBean(DefaultPulsarProducerFactory.class) + .hasFieldOrPropertyWithValue("defaultTopic", "my-topic")); + } + + @Test + void injectsExpectedBeans() { + this.contextRunner + .withPropertyValues("spring.pulsar.producer.topic-name=my-topic", + "spring.pulsar.producer.cache.enabled=false") + .run((context) -> assertThat(context).getBean(DefaultPulsarProducerFactory.class) + .hasFieldOrPropertyWithValue("pulsarClient", context.getBean(PulsarClient.class)) + .hasFieldOrPropertyWithValue("topicResolver", context.getBean(TopicResolver.class))); + } + + @ParameterizedTest + @ValueSource(booleans = { true, false }) + void whenHasUserDefinedCustomizersAppliesInCorrectOrder(boolean cachingEnabled) { + this.contextRunner + .withPropertyValues("spring.pulsar.producer.cache.enabled=" + cachingEnabled, + "spring.pulsar.producer.name=fromPropsCustomizer") + .withUserConfiguration(ProducerBuilderCustomizersConfig.class) + .run((context) -> { + DefaultPulsarProducerFactory producerFactory = context + .getBean(DefaultPulsarProducerFactory.class); + Customizers, ProducerBuilder> customizers = Customizers + .of(ProducerBuilder.class, ProducerBuilderCustomizer::customize); + assertThat(customizers.fromField(producerFactory, "defaultConfigCustomizers")).callsInOrder( + ProducerBuilder::producerName, "fromPropsCustomizer", "fromCustomizer1", "fromCustomizer2"); + }); + } + + @TestConfiguration(proxyBeanMethods = false) + static class ProducerBuilderCustomizersConfig { + + @Bean + @Order(200) + ProducerBuilderCustomizer customizerFoo() { + return (builder) -> builder.producerName("fromCustomizer2"); + } + + @Bean + @Order(100) + ProducerBuilderCustomizer customizerBar() { + return (builder) -> builder.producerName("fromCustomizer1"); + } + + } + + } + + @Nested + class TemplateTests { + + private final ApplicationContextRunner contextRunner = PulsarAutoConfigurationTests.this.contextRunner; + + @Test + @SuppressWarnings("unchecked") + void whenHasUserDefinedBeanDoesNotAutoConfigureBean() { + PulsarTemplate template = mock(PulsarTemplate.class); + this.contextRunner.withBean("customPulsarTemplate", PulsarTemplate.class, () -> template) + .run((context) -> assertThat(context).getBean(PulsarTemplate.class).isSameAs(template)); + } + + @Test + void injectsExpectedBeans() { + PulsarProducerFactory producerFactory = mock(PulsarProducerFactory.class); + SchemaResolver schemaResolver = mock(SchemaResolver.class); + TopicResolver topicResolver = mock(TopicResolver.class); + this.contextRunner + .withBean("customPulsarProducerFactory", PulsarProducerFactory.class, () -> producerFactory) + .withBean("schemaResolver", SchemaResolver.class, () -> schemaResolver) + .withBean("topicResolver", TopicResolver.class, () -> topicResolver) + .run((context) -> assertThat(context).getBean(PulsarTemplate.class) + .hasFieldOrPropertyWithValue("producerFactory", producerFactory) + .hasFieldOrPropertyWithValue("schemaResolver", schemaResolver) + .hasFieldOrPropertyWithValue("topicResolver", topicResolver)); + } + + @Test + void whenHasUseDefinedProducerInterceptorInjectsBean() { + ProducerInterceptor interceptor = mock(ProducerInterceptor.class); + this.contextRunner.withBean("customProducerInterceptor", ProducerInterceptor.class, () -> interceptor) + .run((context) -> assertThat(context).getBean(PulsarTemplate.class) + .extracting("interceptors") + .asList() + .contains(interceptor)); + } + + @Test + void whenHasUseDefinedProducerInterceptorsInjectsBeansInCorrectOrder() { + this.contextRunner.withUserConfiguration(InterceptorTestConfiguration.class) + .run((context) -> assertThat(context).getBean(PulsarTemplate.class) + .extracting("interceptors") + .asList() + .containsExactly(context.getBean("interceptorBar"), context.getBean("interceptorFoo"))); + } + + @Test + void whenNoPropertiesEnablesObservation() { + this.contextRunner.run((context) -> assertThat(context).getBean(PulsarTemplate.class) + .hasFieldOrPropertyWithValue("observationEnabled", true)); + } + + @Test + void whenObservationsEnabledEnablesObservation() { + this.contextRunner.withPropertyValues("spring.pulsar.template.observations-enabled=true") + .run((context) -> assertThat(context).getBean(PulsarTemplate.class) + .hasFieldOrPropertyWithValue("observationEnabled", true)); + } + + @Test + void whenObservationsDisabledDoesNotEnableObservation() { + this.contextRunner.withPropertyValues("spring.pulsar.template.observations-enabled=false") + .run((context) -> assertThat(context).getBean(PulsarTemplate.class) + .hasFieldOrPropertyWithValue("observationEnabled", false)); + } + + @Configuration(proxyBeanMethods = false) + static class InterceptorTestConfiguration { + + @Bean + @Order(200) + ProducerInterceptor interceptorFoo() { + return mock(ProducerInterceptor.class); + } + + @Bean + @Order(100) + ProducerInterceptor interceptorBar() { + return mock(ProducerInterceptor.class); + } + + } + + } + + @Nested + class ConsumerFactoryTests { + + private final ApplicationContextRunner contextRunner = PulsarAutoConfigurationTests.this.contextRunner; + + @Test + @SuppressWarnings("unchecked") + void whenHasUserDefinedBeanDoesNotAutoConfigureBean() { + PulsarConsumerFactory consumerFactory = mock(PulsarConsumerFactory.class); + this.contextRunner + .withBean("customPulsarConsumerFactory", PulsarConsumerFactory.class, () -> consumerFactory) + .run((context) -> assertThat(context).getBean(PulsarConsumerFactory.class).isSameAs(consumerFactory)); + } + + @Test + void injectsExpectedBeans() { + this.contextRunner.run((context) -> assertThat(context).getBean(DefaultPulsarConsumerFactory.class) + .hasFieldOrPropertyWithValue("pulsarClient", context.getBean(PulsarClient.class))); + } + + @Test + void whenHasUserDefinedCustomizersAppliesInCorrectOrder() { + this.contextRunner.withPropertyValues("spring.pulsar.consumer.name=fromPropsCustomizer") + .withUserConfiguration(ConsumerBuilderCustomizersConfig.class) + .run((context) -> { + DefaultPulsarConsumerFactory consumerFactory = context + .getBean(DefaultPulsarConsumerFactory.class); + Customizers, ConsumerBuilder> customizers = Customizers + .of(ConsumerBuilder.class, ConsumerBuilderCustomizer::customize); + assertThat(customizers.fromField(consumerFactory, "defaultConfigCustomizers")).callsInOrder( + ConsumerBuilder::consumerName, "fromPropsCustomizer", "fromCustomizer1", "fromCustomizer2"); + }); + } + + @TestConfiguration(proxyBeanMethods = false) + static class ConsumerBuilderCustomizersConfig { + + @Bean + @Order(200) + ConsumerBuilderCustomizer customizerFoo() { + return (builder) -> builder.consumerName("fromCustomizer2"); + } + + @Bean + @Order(100) + ConsumerBuilderCustomizer customizerBar() { + return (builder) -> builder.consumerName("fromCustomizer1"); + } + + } + + } + + @Nested + class ListenerTests { + + private final ApplicationContextRunner contextRunner = PulsarAutoConfigurationTests.this.contextRunner; + + @Test + void whenHasUserDefinedListenerContainerFactoryBeanDoesNotAutoConfigureBean() { + PulsarListenerContainerFactory listenerContainerFactory = mock(PulsarListenerContainerFactory.class); + this.contextRunner + .withBean("pulsarListenerContainerFactory", PulsarListenerContainerFactory.class, + () -> listenerContainerFactory) + .run((context) -> assertThat(context).getBean(PulsarListenerContainerFactory.class) + .isSameAs(listenerContainerFactory)); + } + + @Test + @SuppressWarnings("rawtypes") + void injectsExpectedBeans() { + PulsarConsumerFactory consumerFactory = mock(PulsarConsumerFactory.class); + SchemaResolver schemaResolver = mock(SchemaResolver.class); + TopicResolver topicResolver = mock(TopicResolver.class); + this.contextRunner.withBean("pulsarConsumerFactory", PulsarConsumerFactory.class, () -> consumerFactory) + .withBean("schemaResolver", SchemaResolver.class, () -> schemaResolver) + .withBean("topicResolver", TopicResolver.class, () -> topicResolver) + .run((context) -> assertThat(context).getBean(ConcurrentPulsarListenerContainerFactory.class) + .hasFieldOrPropertyWithValue("consumerFactory", consumerFactory) + .extracting(ConcurrentPulsarListenerContainerFactory::getContainerProperties) + .hasFieldOrPropertyWithValue("schemaResolver", schemaResolver) + .hasFieldOrPropertyWithValue("topicResolver", topicResolver)); + } + + @Test + @SuppressWarnings("unchecked") + void whenHasUserDefinedListenerAnnotationBeanPostProcessorBeanDoesNotAutoConfigureBean() { + PulsarListenerAnnotationBeanPostProcessor listenerAnnotationBeanPostProcessor = mock( + PulsarListenerAnnotationBeanPostProcessor.class); + this.contextRunner + .withBean("org.springframework.pulsar.config.internalPulsarListenerAnnotationProcessor", + PulsarListenerAnnotationBeanPostProcessor.class, () -> listenerAnnotationBeanPostProcessor) + .run((context) -> assertThat(context).getBean(PulsarListenerAnnotationBeanPostProcessor.class) + .isSameAs(listenerAnnotationBeanPostProcessor)); + } + + @Test + void whenHasCustomProperties() { + List properties = new ArrayList<>(); + properties.add("spring.pulsar.listener.schema-type=avro"); + this.contextRunner.withPropertyValues(properties.toArray(String[]::new)).run((context) -> { + ConcurrentPulsarListenerContainerFactory factory = context + .getBean(ConcurrentPulsarListenerContainerFactory.class); + assertThat(factory.getContainerProperties().getSchemaType()).isEqualTo(SchemaType.AVRO); + }); + } + + @Test + void whenNoPropertiesEnablesObservation() { + this.contextRunner + .run((context) -> assertThat(context).getBean(ConcurrentPulsarListenerContainerFactory.class) + .hasFieldOrPropertyWithValue("containerProperties.observationEnabled", true)); + } + + @Test + void whenObservationsEnabledEnablesObservation() { + this.contextRunner.withPropertyValues("spring.pulsar.listener.observation-enabled=true") + .run((context) -> assertThat(context).getBean(ConcurrentPulsarListenerContainerFactory.class) + .hasFieldOrPropertyWithValue("containerProperties.observationEnabled", true)); + } + + @Test + void whenObservationsDisabledDoesNotEnableObservation() { + this.contextRunner.withPropertyValues("spring.pulsar.listener.observation-enabled=false") + .run((context) -> assertThat(context).getBean(ConcurrentPulsarListenerContainerFactory.class) + .hasFieldOrPropertyWithValue("containerProperties.observationEnabled", false)); + } + + } + + @Nested + class ReaderFactoryTests { + + private final ApplicationContextRunner contextRunner = PulsarAutoConfigurationTests.this.contextRunner; + + @Test + @SuppressWarnings("unchecked") + void whenHasUserDefinedBeanDoesNotAutoConfigureBean() { + PulsarReaderFactory readerFactory = mock(PulsarReaderFactory.class); + this.contextRunner.withBean("customPulsarReaderFactory", PulsarReaderFactory.class, () -> readerFactory) + .run((context) -> assertThat(context).getBean(PulsarReaderFactory.class).isSameAs(readerFactory)); + } + + @Test + void injectsExpectedBeans() { + this.contextRunner.run((context) -> assertThat(context).getBean(DefaultPulsarReaderFactory.class) + .hasFieldOrPropertyWithValue("pulsarClient", context.getBean(PulsarClient.class))); + } + + @Test + void whenHasUserDefinedCustomizersAppliesInCorrectOrder() { + this.contextRunner.withPropertyValues("spring.pulsar.reader.name=fromPropsCustomizer") + .withUserConfiguration(ReaderBuilderCustomizersConfig.class) + .run((context) -> { + DefaultPulsarReaderFactory readerFactory = context.getBean(DefaultPulsarReaderFactory.class); + Customizers, ReaderBuilder> customizers = Customizers + .of(ReaderBuilder.class, ReaderBuilderCustomizer::customize); + assertThat(customizers.fromField(readerFactory, "defaultConfigCustomizers")).callsInOrder( + ReaderBuilder::readerName, "fromPropsCustomizer", "fromCustomizer1", "fromCustomizer2"); + }); + } + + @TestConfiguration(proxyBeanMethods = false) + static class ReaderBuilderCustomizersConfig { + + @Bean + @Order(200) + ReaderBuilderCustomizer customizerFoo() { + return (builder) -> builder.readerName("fromCustomizer2"); + } + + @Bean + @Order(100) + ReaderBuilderCustomizer customizerBar() { + return (builder) -> builder.readerName("fromCustomizer1"); + } + + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarConfigurationTests.java new file mode 100644 index 0000000000..70536effac --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarConfigurationTests.java @@ -0,0 +1,318 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.pulsar; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import org.apache.pulsar.client.admin.PulsarAdminBuilder; +import org.apache.pulsar.client.api.ClientBuilder; +import org.apache.pulsar.client.api.PulsarClient; +import org.apache.pulsar.client.api.Schema; +import org.apache.pulsar.common.schema.KeyValueEncodingType; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.assertj.core.api.InstanceOfAssertFactory; +import org.assertj.core.api.MapAssert; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.core.annotation.Order; +import org.springframework.pulsar.core.DefaultPulsarClientFactory; +import org.springframework.pulsar.core.DefaultSchemaResolver; +import org.springframework.pulsar.core.DefaultTopicResolver; +import org.springframework.pulsar.core.PulsarAdminBuilderCustomizer; +import org.springframework.pulsar.core.PulsarAdministration; +import org.springframework.pulsar.core.PulsarClientBuilderCustomizer; +import org.springframework.pulsar.core.PulsarClientFactory; +import org.springframework.pulsar.core.SchemaResolver; +import org.springframework.pulsar.core.SchemaResolver.SchemaResolverCustomizer; +import org.springframework.pulsar.core.TopicResolver; +import org.springframework.pulsar.function.PulsarFunctionAdministration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.entry; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link PulsarConfiguration}. + * + * @author Chris Bono + * @author Alexander Preuß + * @author Soby Chacko + * @author Phillip Webb + */ +class PulsarConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(PulsarConfiguration.class)) + .withBean(PulsarClient.class, () -> mock(PulsarClient.class)); + + @Nested + class ClientTests { + + @Test + void whenHasUserDefinedClientFactoryBeanDoesNotAutoConfigureBean() { + PulsarClientFactory customFactory = mock(PulsarClientFactory.class); + new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(PulsarConfiguration.class)) + .withBean("customPulsarClientFactory", PulsarClientFactory.class, () -> customFactory) + .run((context) -> assertThat(context).getBean(PulsarClientFactory.class).isSameAs(customFactory)); + } + + @Test + void whenHasUserDefinedClientBeanDoesNotAutoConfigureBean() { + PulsarClient customClient = mock(PulsarClient.class); + new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(PulsarConfiguration.class)) + .withBean("customPulsarClient", PulsarClient.class, () -> customClient) + .run((context) -> assertThat(context).getBean(PulsarClient.class).isSameAs(customClient)); + } + + @Test + void whenHasUserDefinedCustomizersAppliesInCorrectOrder() { + PulsarConfigurationTests.this.contextRunner + .withUserConfiguration(PulsarClientBuilderCustomizersConfig.class) + .withPropertyValues("spring.pulsar.client.service-url=fromPropsCustomizer") + .run((context) -> { + DefaultPulsarClientFactory clientFactory = context.getBean(DefaultPulsarClientFactory.class); + Customizers customizers = Customizers + .of(ClientBuilder.class, PulsarClientBuilderCustomizer::customize); + assertThat(customizers.fromField(clientFactory, "customizer")).callsInOrder( + ClientBuilder::serviceUrl, "fromPropsCustomizer", "fromCustomizer1", "fromCustomizer2"); + }); + } + + @TestConfiguration(proxyBeanMethods = false) + static class PulsarClientBuilderCustomizersConfig { + + @Bean + @Order(200) + PulsarClientBuilderCustomizer customizerFoo() { + return (builder) -> builder.serviceUrl("fromCustomizer2"); + } + + @Bean + @Order(100) + PulsarClientBuilderCustomizer customizerBar() { + return (builder) -> builder.serviceUrl("fromCustomizer1"); + } + + } + + } + + @Nested + class AdministrationTests { + + private final ApplicationContextRunner contextRunner = PulsarConfigurationTests.this.contextRunner; + + @Test + void whenHasUserDefinedBeanDoesNotAutoConfigureBean() { + PulsarAdministration pulsarAdministration = mock(PulsarAdministration.class); + this.contextRunner + .withBean("customPulsarAdministration", PulsarAdministration.class, () -> pulsarAdministration) + .run((context) -> assertThat(context).getBean(PulsarAdministration.class) + .isSameAs(pulsarAdministration)); + } + + @Test + void whenHasUserDefinedCustomizersAppliesInCorrectOrder() { + this.contextRunner.withUserConfiguration(PulsarAdminBuilderCustomizersConfig.class) + .withPropertyValues("spring.pulsar.admin.service-url=fromPropsCustomizer") + .run((context) -> { + PulsarAdministration pulsarAdmin = context.getBean(PulsarAdministration.class); + Customizers customizers = Customizers + .of(PulsarAdminBuilder.class, PulsarAdminBuilderCustomizer::customize); + assertThat(customizers.fromField(pulsarAdmin, "adminCustomizers")).callsInOrder( + PulsarAdminBuilder::serviceHttpUrl, "fromPropsCustomizer", "fromCustomizer1", + "fromCustomizer2"); + }); + } + + @TestConfiguration(proxyBeanMethods = false) + static class PulsarAdminBuilderCustomizersConfig { + + @Bean + @Order(200) + PulsarAdminBuilderCustomizer customizerFoo() { + return (builder) -> builder.serviceHttpUrl("fromCustomizer2"); + } + + @Bean + @Order(100) + PulsarAdminBuilderCustomizer customizerBar() { + return (builder) -> builder.serviceHttpUrl("fromCustomizer1"); + } + + } + + } + + @Nested + class SchemaResolverTests { + + @SuppressWarnings("rawtypes") + private static final InstanceOfAssertFactory> CLASS_SCHEMA_MAP = InstanceOfAssertFactories + .map(Class.class, Schema.class); + + private final ApplicationContextRunner contextRunner = PulsarConfigurationTests.this.contextRunner; + + @Test + void whenHasUserDefinedBeanDoesNotAutoConfigureBean() { + SchemaResolver schemaResolver = mock(SchemaResolver.class); + this.contextRunner.withBean("customSchemaResolver", SchemaResolver.class, () -> schemaResolver) + .run((context) -> assertThat(context).getBean(SchemaResolver.class).isSameAs(schemaResolver)); + } + + @Test + void whenHasUserDefinedSchemaResolverCustomizer() { + SchemaResolverCustomizer customizer = (schemaResolver) -> schemaResolver + .addCustomSchemaMapping(TestRecord.class, Schema.STRING); + this.contextRunner.withBean("schemaResolverCustomizer", SchemaResolverCustomizer.class, () -> customizer) + .run((context) -> assertThat(context).getBean(DefaultSchemaResolver.class) + .extracting(DefaultSchemaResolver::getCustomSchemaMappings, InstanceOfAssertFactories.MAP) + .containsEntry(TestRecord.class, Schema.STRING)); + } + + @Test + void whenHasDefaultsTypeMappingForPrimitiveAddsToSchemaResolver() { + List properties = new ArrayList<>(); + properties.add("spring.pulsar.defaults.type-mappings[0].message-type=" + TestRecord.CLASS_NAME); + properties.add("spring.pulsar.defaults.type-mappings[0].schema-info.schema-type=STRING"); + this.contextRunner.withPropertyValues(properties.toArray(String[]::new)) + .run((context) -> assertThat(context).getBean(DefaultSchemaResolver.class) + .extracting(DefaultSchemaResolver::getCustomSchemaMappings, InstanceOfAssertFactories.MAP) + .containsOnly(entry(TestRecord.class, Schema.STRING))); + } + + @Test + void whenHasDefaultsTypeMappingForStructAddsToSchemaResolver() { + List properties = new ArrayList<>(); + properties.add("spring.pulsar.defaults.type-mappings[0].message-type=" + TestRecord.CLASS_NAME); + properties.add("spring.pulsar.defaults.type-mappings[0].schema-info.schema-type=JSON"); + Schema expectedSchema = Schema.JSON(TestRecord.class); + this.contextRunner.withPropertyValues(properties.toArray(String[]::new)) + .run((context) -> assertThat(context).getBean(DefaultSchemaResolver.class) + .extracting(DefaultSchemaResolver::getCustomSchemaMappings, CLASS_SCHEMA_MAP) + .hasEntrySatisfying(TestRecord.class, schemaEqualTo(expectedSchema))); + } + + @Test + void whenHasDefaultsTypeMappingForKeyValueAddsToSchemaResolver() { + List properties = new ArrayList<>(); + properties.add("spring.pulsar.defaults.type-mappings[0].message-type=" + TestRecord.CLASS_NAME); + properties.add("spring.pulsar.defaults.type-mappings[0].schema-info.schema-type=key-value"); + properties.add("spring.pulsar.defaults.type-mappings[0].schema-info.message-key-type=java.lang.String"); + Schema expectedSchema = Schema.KeyValue(Schema.STRING, Schema.JSON(TestRecord.class), + KeyValueEncodingType.INLINE); + this.contextRunner.withPropertyValues(properties.toArray(String[]::new)) + .run((context) -> assertThat(context).getBean(DefaultSchemaResolver.class) + .extracting(DefaultSchemaResolver::getCustomSchemaMappings, CLASS_SCHEMA_MAP) + .hasEntrySatisfying(TestRecord.class, schemaEqualTo(expectedSchema))); + } + + @SuppressWarnings("rawtypes") + private Consumer schemaEqualTo(Schema expected) { + return (actual) -> assertThat(actual.getSchemaInfo()).isEqualTo(expected.getSchemaInfo()); + } + + } + + @Nested + class TopicResolverTests { + + private final ApplicationContextRunner contextRunner = PulsarConfigurationTests.this.contextRunner; + + @Test + void whenHasUserDefinedBeanDoesNotAutoConfigureBean() { + TopicResolver topicResolver = mock(TopicResolver.class); + this.contextRunner.withBean("customTopicResolver", TopicResolver.class, () -> topicResolver) + .run((context) -> assertThat(context).getBean(TopicResolver.class).isSameAs(topicResolver)); + } + + @Test + void whenHasDefaultsTypeMappingAddsToSchemaResolver() { + List properties = new ArrayList<>(); + properties.add("spring.pulsar.defaults.type-mappings[0].message-type=" + TestRecord.CLASS_NAME); + properties.add("spring.pulsar.defaults.type-mappings[0].topic-name=foo-topic"); + properties.add("spring.pulsar.defaults.type-mappings[1].message-type=java.lang.String"); + properties.add("spring.pulsar.defaults.type-mappings[1].topic-name=string-topic"); + this.contextRunner.withPropertyValues(properties.toArray(String[]::new)) + .run((context) -> assertThat(context).getBean(TopicResolver.class) + .asInstanceOf(InstanceOfAssertFactories.type(DefaultTopicResolver.class)) + .extracting(DefaultTopicResolver::getCustomTopicMappings, InstanceOfAssertFactories.MAP) + .containsOnly(entry(TestRecord.class, "foo-topic"), entry(String.class, "string-topic"))); + } + + } + + @Nested + class FunctionAdministrationTests { + + private final ApplicationContextRunner contextRunner = PulsarConfigurationTests.this.contextRunner; + + @Test + void whenNoPropertiesAddsFunctionAdministrationBean() { + this.contextRunner.run((context) -> assertThat(context).getBean(PulsarFunctionAdministration.class) + .hasFieldOrPropertyWithValue("failFast", Boolean.TRUE) + .hasFieldOrPropertyWithValue("propagateFailures", Boolean.TRUE) + .hasFieldOrPropertyWithValue("propagateStopFailures", Boolean.FALSE) + .hasNoNullFieldsOrProperties() // ensures object providers set + .extracting("pulsarAdministration") + .isSameAs(context.getBean(PulsarAdministration.class))); + } + + @Test + void whenHasFunctionPropertiesAppliesPropertiesToBean() { + List properties = new ArrayList<>(); + properties.add("spring.pulsar.function.fail-fast=false"); + properties.add("spring.pulsar.function.propagate-failures=false"); + properties.add("spring.pulsar.function.propagate-stop-failures=true"); + this.contextRunner.withPropertyValues(properties.toArray(String[]::new)) + .run((context) -> assertThat(context).getBean(PulsarFunctionAdministration.class) + .hasFieldOrPropertyWithValue("failFast", Boolean.FALSE) + .hasFieldOrPropertyWithValue("propagateFailures", Boolean.FALSE) + .hasFieldOrPropertyWithValue("propagateStopFailures", Boolean.TRUE)); + } + + @Test + void whenHasFunctionDisabledPropertyDoesNotCreateBean() { + this.contextRunner.withPropertyValues("spring.pulsar.function.enabled=false") + .run((context) -> assertThat(context).doesNotHaveBean(PulsarFunctionAdministration.class)); + } + + @Test + void whenHasCustomFunctionAdministrationBean() { + PulsarFunctionAdministration functionAdministration = mock(PulsarFunctionAdministration.class); + this.contextRunner.withBean(PulsarFunctionAdministration.class, () -> functionAdministration) + .run((context) -> assertThat(context).getBean(PulsarFunctionAdministration.class) + .isSameAs(functionAdministration)); + } + + } + + record TestRecord() { + + private static final String CLASS_NAME = TestRecord.class.getName(); + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarPropertiesMapperTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarPropertiesMapperTests.java new file mode 100644 index 0000000000..283e06c1d4 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarPropertiesMapperTests.java @@ -0,0 +1,190 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.pulsar; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; + +import org.apache.pulsar.client.admin.PulsarAdminBuilder; +import org.apache.pulsar.client.api.ClientBuilder; +import org.apache.pulsar.client.api.CompressionType; +import org.apache.pulsar.client.api.ConsumerBuilder; +import org.apache.pulsar.client.api.DeadLetterPolicy; +import org.apache.pulsar.client.api.HashingScheme; +import org.apache.pulsar.client.api.MessageRoutingMode; +import org.apache.pulsar.client.api.ProducerAccessMode; +import org.apache.pulsar.client.api.ProducerBuilder; +import org.apache.pulsar.client.api.PulsarClientException.UnsupportedAuthenticationException; +import org.apache.pulsar.client.api.ReaderBuilder; +import org.apache.pulsar.client.api.SubscriptionType; +import org.apache.pulsar.common.schema.SchemaType; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.pulsar.PulsarProperties.Consumer; +import org.springframework.pulsar.listener.PulsarContainerProperties; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link PulsarPropertiesMapper}. + * + * @author Chris Bono + * @author Phillip Webb + */ +class PulsarPropertiesMapperTests { + + @Test + void customizeClientBuilderWhenHasNoAuthentication() { + PulsarProperties properties = new PulsarProperties(); + properties.getClient().setServiceUrl("https://example.com"); + properties.getClient().setConnectionTimeout(Duration.ofSeconds(1)); + properties.getClient().setOperationTimeout(Duration.ofSeconds(2)); + properties.getClient().setLookupTimeout(Duration.ofSeconds(3)); + ClientBuilder builder = mock(ClientBuilder.class); + new PulsarPropertiesMapper(properties).customizeClientBuilder(builder); + then(builder).should().serviceUrl("https://example.com"); + then(builder).should().connectionTimeout(1000, TimeUnit.MILLISECONDS); + then(builder).should().operationTimeout(2000, TimeUnit.MILLISECONDS); + then(builder).should().lookupTimeout(3000, TimeUnit.MILLISECONDS); + } + + @Test + void customizeClientBuilderWhenHasAuthentication() throws UnsupportedAuthenticationException { + PulsarProperties properties = new PulsarProperties(); + Map params = Map.of("param", "name"); + properties.getClient().getAuthentication().setPluginClassName("myclass"); + properties.getClient().getAuthentication().setParam(params); + ClientBuilder builder = mock(ClientBuilder.class); + new PulsarPropertiesMapper(properties).customizeClientBuilder(builder); + then(builder).should().authentication("myclass", params); + } + + @Test + void customizeAdminBuilderWhenHasNoAuthentication() { + PulsarProperties properties = new PulsarProperties(); + properties.getAdmin().setServiceUrl("https://example.com"); + properties.getAdmin().setConnectionTimeout(Duration.ofSeconds(1)); + properties.getAdmin().setReadTimeout(Duration.ofSeconds(2)); + properties.getAdmin().setRequestTimeout(Duration.ofSeconds(3)); + PulsarAdminBuilder builder = mock(PulsarAdminBuilder.class); + new PulsarPropertiesMapper(properties).customizeAdminBuilder(builder); + then(builder).should().serviceHttpUrl("https://example.com"); + then(builder).should().connectionTimeout(1000, TimeUnit.MILLISECONDS); + then(builder).should().readTimeout(2000, TimeUnit.MILLISECONDS); + then(builder).should().requestTimeout(3000, TimeUnit.MILLISECONDS); + } + + @Test + void customizeAdminBuilderWhenHasAuthentication() throws UnsupportedAuthenticationException { + PulsarProperties properties = new PulsarProperties(); + Map params = Map.of("param", "name"); + properties.getAdmin().getAuthentication().setPluginClassName("myclass"); + properties.getAdmin().getAuthentication().setParam(params); + PulsarAdminBuilder builder = mock(PulsarAdminBuilder.class); + new PulsarPropertiesMapper(properties).customizeAdminBuilder(builder); + then(builder).should().authentication("myclass", params); + } + + @Test + @SuppressWarnings("unchecked") + void customizeProducerBuilder() { + PulsarProperties properties = new PulsarProperties(); + properties.getProducer().setName("name"); + properties.getProducer().setTopicName("topicname"); + properties.getProducer().setSendTimeout(Duration.ofSeconds(1)); + properties.getProducer().setMessageRoutingMode(MessageRoutingMode.RoundRobinPartition); + properties.getProducer().setHashingScheme(HashingScheme.JavaStringHash); + properties.getProducer().setBatchingEnabled(false); + properties.getProducer().setChunkingEnabled(true); + properties.getProducer().setCompressionType(CompressionType.SNAPPY); + properties.getProducer().setAccessMode(ProducerAccessMode.Exclusive); + ProducerBuilder builder = mock(ProducerBuilder.class); + new PulsarPropertiesMapper(properties).customizeProducerBuilder(builder); + then(builder).should().producerName("name"); + then(builder).should().topic("topicname"); + then(builder).should().sendTimeout(1000, TimeUnit.MILLISECONDS); + then(builder).should().messageRoutingMode(MessageRoutingMode.RoundRobinPartition); + then(builder).should().hashingScheme(HashingScheme.JavaStringHash); + then(builder).should().enableBatching(false); + then(builder).should().enableChunking(true); + then(builder).should().compressionType(CompressionType.SNAPPY); + then(builder).should().accessMode(ProducerAccessMode.Exclusive); + } + + @Test + @SuppressWarnings("unchecked") + void customizeConsumerBuilder() { + PulsarProperties properties = new PulsarProperties(); + List topics = List.of("mytopic"); + Pattern topisPattern = Pattern.compile("my-pattern"); + properties.getConsumer().setName("name"); + properties.getConsumer().setTopics(topics); + properties.getConsumer().setTopicsPattern(topisPattern); + properties.getConsumer().setPriorityLevel(123); + properties.getConsumer().setReadCompacted(true); + Consumer.DeadLetterPolicy deadLetterPolicy = new Consumer.DeadLetterPolicy(); + deadLetterPolicy.setDeadLetterTopic("my-dlt"); + deadLetterPolicy.setMaxRedeliverCount(1); + properties.getConsumer().setDeadLetterPolicy(deadLetterPolicy); + ConsumerBuilder builder = mock(ConsumerBuilder.class); + new PulsarPropertiesMapper(properties).customizeConsumerBuilder(builder); + then(builder).should().consumerName("name"); + then(builder).should().topics(topics); + then(builder).should().topicsPattern(topisPattern); + then(builder).should().priorityLevel(123); + then(builder).should().readCompacted(true); + then(builder).should().deadLetterPolicy(new DeadLetterPolicy(1, null, "my-dlt", null)); + } + + @Test + void customizeContainerProperties() { + PulsarProperties properties = new PulsarProperties(); + properties.getConsumer().getSubscription().setType(SubscriptionType.Shared); + properties.getListener().setSchemaType(SchemaType.AVRO); + properties.getListener().setObservationEnabled(false); + PulsarContainerProperties containerProperties = new PulsarContainerProperties("my-topic-pattern"); + new PulsarPropertiesMapper(properties).customizeContainerProperties(containerProperties); + assertThat(containerProperties.getSubscriptionType()).isEqualTo(SubscriptionType.Shared); + assertThat(containerProperties.getSchemaType()).isEqualTo(SchemaType.AVRO); + assertThat(containerProperties.isObservationEnabled()).isFalse(); + } + + @Test + @SuppressWarnings("unchecked") + void customizeReaderBuilder() { + PulsarProperties properties = new PulsarProperties(); + List topics = List.of("mytopic"); + properties.getReader().setName("name"); + properties.getReader().setTopics(topics); + properties.getReader().setSubscriptionName("subname"); + properties.getReader().setSubscriptionRolePrefix("subroleprefix"); + properties.getReader().setReadCompacted(true); + ReaderBuilder builder = mock(ReaderBuilder.class); + new PulsarPropertiesMapper(properties).customizeReaderBuilder(builder); + then(builder).should().readerName("name"); + then(builder).should().topics(topics); + then(builder).should().subscriptionName("subname"); + then(builder).should().subscriptionRolePrefix("subroleprefix"); + then(builder).should().readCompacted(true); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarPropertiesTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarPropertiesTests.java new file mode 100644 index 0000000000..48c17247a4 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarPropertiesTests.java @@ -0,0 +1,365 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.pulsar; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +import org.apache.pulsar.client.api.CompressionType; +import org.apache.pulsar.client.api.HashingScheme; +import org.apache.pulsar.client.api.MessageRoutingMode; +import org.apache.pulsar.client.api.ProducerAccessMode; +import org.apache.pulsar.client.api.RegexSubscriptionMode; +import org.apache.pulsar.client.api.SubscriptionInitialPosition; +import org.apache.pulsar.client.api.SubscriptionMode; +import org.apache.pulsar.client.api.SubscriptionType; +import org.apache.pulsar.common.schema.SchemaType; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.pulsar.PulsarProperties.Defaults.SchemaInfo; +import org.springframework.boot.autoconfigure.pulsar.PulsarProperties.Defaults.TypeMapping; +import org.springframework.boot.context.properties.bind.BindException; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link PulsarProperties}. + * + * @author Chris Bono + * @author Christophe Bornet + * @author Soby Chacko + * @author Phillip Webb + */ +class PulsarPropertiesTests { + + private PulsarProperties bindPropeties(Map map) { + return new Binder(new MapConfigurationPropertySource(map)).bind("spring.pulsar", PulsarProperties.class).get(); + } + + @Nested + class ClientProperties { + + @Test + void bind() { + Map map = new HashMap<>(); + map.put("spring.pulsar.client.service-url", "my-service-url"); + map.put("spring.pulsar.client.operation-timeout", "1s"); + map.put("spring.pulsar.client.lookup-timeout", "2s"); + map.put("spring.pulsar.client.connection-timeout", "12s"); + PulsarProperties.Client properties = bindPropeties(map).getClient(); + assertThat(properties.getServiceUrl()).isEqualTo("my-service-url"); + assertThat(properties.getOperationTimeout()).isEqualTo(Duration.ofMillis(1000)); + assertThat(properties.getLookupTimeout()).isEqualTo(Duration.ofMillis(2000)); + assertThat(properties.getConnectionTimeout()).isEqualTo(Duration.ofMillis(12000)); + } + + @Test + void bindAuthentication() { + Map map = new HashMap<>(); + map.put("spring.pulsar.client.authentication.plugin-class-name", "com.example.MyAuth"); + map.put("spring.pulsar.client.authentication.param.token", "1234"); + PulsarProperties.Client properties = bindPropeties(map).getClient(); + assertThat(properties.getAuthentication().getPluginClassName()).isEqualTo("com.example.MyAuth"); + assertThat(properties.getAuthentication().getParam()).containsEntry("token", "1234"); + } + + } + + @Nested + class AdminProperties { + + private final String authPluginClassName = "org.apache.pulsar.client.impl.auth.AuthenticationToken"; + + private final String authToken = "1234"; + + @Test + void bind() { + Map map = new HashMap<>(); + map.put("spring.pulsar.admin.service-url", "my-service-url"); + map.put("spring.pulsar.admin.connection-timeout", "12s"); + map.put("spring.pulsar.admin.read-timeout", "13s"); + map.put("spring.pulsar.admin.request-timeout", "14s"); + PulsarProperties.Admin properties = bindPropeties(map).getAdmin(); + assertThat(properties.getServiceUrl()).isEqualTo("my-service-url"); + assertThat(properties.getConnectionTimeout()).isEqualTo(Duration.ofSeconds(12)); + assertThat(properties.getReadTimeout()).isEqualTo(Duration.ofSeconds(13)); + assertThat(properties.getRequestTimeout()).isEqualTo(Duration.ofSeconds(14)); + } + + @Test + void bindAuthentication() { + Map map = new HashMap<>(); + map.put("spring.pulsar.admin.authentication.plugin-class-name", this.authPluginClassName); + map.put("spring.pulsar.admin.authentication.param.token", this.authToken); + PulsarProperties.Admin properties = bindPropeties(map).getAdmin(); + assertThat(properties.getAuthentication().getPluginClassName()).isEqualTo(this.authPluginClassName); + assertThat(properties.getAuthentication().getParam()).containsEntry("token", this.authToken); + } + + } + + @Nested + class DefaultsProperties { + + @Test + void bindWhenNoTypeMappings() { + assertThat(new PulsarProperties().getDefaults().getTypeMappings()).isEmpty(); + } + + @Test + void bindWhenTypeMappingsWithTopicsOnly() { + Map map = new HashMap<>(); + map.put("spring.pulsar.defaults.type-mappings[0].message-type", TestMessage.class.getName()); + map.put("spring.pulsar.defaults.type-mappings[0].topic-name", "foo-topic"); + map.put("spring.pulsar.defaults.type-mappings[1].message-type", String.class.getName()); + map.put("spring.pulsar.defaults.type-mappings[1].topic-name", "string-topic"); + PulsarProperties.Defaults properties = bindPropeties(map).getDefaults(); + TypeMapping expectedTopic1 = new TypeMapping(TestMessage.class, "foo-topic", null); + TypeMapping expectedTopic2 = new TypeMapping(String.class, "string-topic", null); + assertThat(properties.getTypeMappings()).containsExactly(expectedTopic1, expectedTopic2); + } + + @Test + void bindWhenTypeMappingsWithSchemaOnly() { + Map map = new HashMap<>(); + map.put("spring.pulsar.defaults.type-mappings[0].message-type", TestMessage.class.getName()); + map.put("spring.pulsar.defaults.type-mappings[0].schema-info.schema-type", "JSON"); + PulsarProperties.Defaults properties = bindPropeties(map).getDefaults(); + TypeMapping expected = new TypeMapping(TestMessage.class, null, new SchemaInfo(SchemaType.JSON, null)); + assertThat(properties.getTypeMappings()).containsExactly(expected); + } + + @Test + void bindWhenTypeMappingsWithTopicAndSchema() { + Map map = new HashMap<>(); + map.put("spring.pulsar.defaults.type-mappings[0].message-type", TestMessage.class.getName()); + map.put("spring.pulsar.defaults.type-mappings[0].topic-name", "foo-topic"); + map.put("spring.pulsar.defaults.type-mappings[0].schema-info.schema-type", "JSON"); + PulsarProperties.Defaults properties = bindPropeties(map).getDefaults(); + TypeMapping expected = new TypeMapping(TestMessage.class, "foo-topic", + new SchemaInfo(SchemaType.JSON, null)); + assertThat(properties.getTypeMappings()).containsExactly(expected); + } + + @Test + void bindWhenTypeMappingsWithKeyValueSchema() { + Map map = new HashMap<>(); + map.put("spring.pulsar.defaults.type-mappings[0].message-type", TestMessage.class.getName()); + map.put("spring.pulsar.defaults.type-mappings[0].schema-info.schema-type", "KEY_VALUE"); + map.put("spring.pulsar.defaults.type-mappings[0].schema-info.message-key-type", String.class.getName()); + PulsarProperties.Defaults properties = bindPropeties(map).getDefaults(); + TypeMapping expected = new TypeMapping(TestMessage.class, null, + new SchemaInfo(SchemaType.KEY_VALUE, String.class)); + assertThat(properties.getTypeMappings()).containsExactly(expected); + } + + @Test + void bindWhenNoSchemaThrowsException() { + Map map = new HashMap<>(); + map.put("spring.pulsar.defaults.type-mappings[0].message-type", TestMessage.class.getName()); + map.put("spring.pulsar.defaults.type-mappings[0].schema-info.message-key-type", String.class.getName()); + assertThatExceptionOfType(BindException.class).isThrownBy(() -> bindPropeties(map)) + .havingRootCause() + .withMessageContaining("schemaType must not be null"); + } + + @Test + void bindWhenSchemaTypeNoneThrowsException() { + Map map = new HashMap<>(); + map.put("spring.pulsar.defaults.type-mappings[0].message-type", TestMessage.class.getName()); + map.put("spring.pulsar.defaults.type-mappings[0].schema-info.schema-type", "NONE"); + assertThatExceptionOfType(BindException.class).isThrownBy(() -> bindPropeties(map)) + .havingRootCause() + .withMessageContaining("schemaType 'NONE' not supported"); + } + + @Test + void bindWhenMessageKeyTypeSetOnNonKeyValueSchemaThrowsException() { + Map map = new HashMap<>(); + map.put("spring.pulsar.defaults.type-mappings[0].message-type", TestMessage.class.getName()); + map.put("spring.pulsar.defaults.type-mappings[0].schema-info.schema-type", "JSON"); + map.put("spring.pulsar.defaults.type-mappings[0].schema-info.message-key-type", String.class.getName()); + assertThatExceptionOfType(BindException.class).isThrownBy(() -> bindPropeties(map)) + .havingRootCause() + .withMessageContaining("messageKeyType can only be set when schemaType is KEY_VALUE"); + } + + record TestMessage(String value) { + } + + } + + @Nested + class FunctionProperties { + + @Test + void defaults() { + PulsarProperties.Function properties = new PulsarProperties.Function(); + assertThat(properties.isFailFast()).isTrue(); + assertThat(properties.isPropagateFailures()).isTrue(); + assertThat(properties.isPropagateStopFailures()).isFalse(); + } + + @Test + void bind() { + Map props = new HashMap<>(); + props.put("spring.pulsar.function.fail-fast", "false"); + props.put("spring.pulsar.function.propagate-failures", "false"); + props.put("spring.pulsar.function.propagate-stop-failures", "true"); + PulsarProperties.Function properties = bindPropeties(props).getFunction(); + assertThat(properties.isFailFast()).isFalse(); + assertThat(properties.isPropagateFailures()).isFalse(); + assertThat(properties.isPropagateStopFailures()).isTrue(); + } + + } + + @Nested + class ProducerProperties { + + @Test + void bind() { + Map map = new HashMap<>(); + map.put("spring.pulsar.producer.name", "my-producer"); + map.put("spring.pulsar.producer.topic-name", "my-topic"); + map.put("spring.pulsar.producer.send-timeout", "2s"); + map.put("spring.pulsar.producer.message-routing-mode", "custompartition"); + map.put("spring.pulsar.producer.hashing-scheme", "murmur3_32hash"); + map.put("spring.pulsar.producer.batching-enabled", "false"); + map.put("spring.pulsar.producer.chunking-enabled", "true"); + map.put("spring.pulsar.producer.compression-type", "lz4"); + map.put("spring.pulsar.producer.access-mode", "exclusive"); + map.put("spring.pulsar.producer.cache.expire-after-access", "2s"); + map.put("spring.pulsar.producer.cache.maximum-size", "3"); + map.put("spring.pulsar.producer.cache.initial-capacity", "5"); + PulsarProperties.Producer properties = bindPropeties(map).getProducer(); + assertThat(properties.getName()).isEqualTo("my-producer"); + assertThat(properties.getTopicName()).isEqualTo("my-topic"); + assertThat(properties.getSendTimeout()).isEqualTo(Duration.ofSeconds(2)); + assertThat(properties.getMessageRoutingMode()).isEqualTo(MessageRoutingMode.CustomPartition); + assertThat(properties.getHashingScheme()).isEqualTo(HashingScheme.Murmur3_32Hash); + assertThat(properties.isBatchingEnabled()).isFalse(); + assertThat(properties.isChunkingEnabled()).isTrue(); + assertThat(properties.getCompressionType()).isEqualTo(CompressionType.LZ4); + assertThat(properties.getAccessMode()).isEqualTo(ProducerAccessMode.Exclusive); + assertThat(properties.getCache().getExpireAfterAccess()).isEqualTo(Duration.ofSeconds(2)); + assertThat(properties.getCache().getMaximumSize()).isEqualTo(3); + assertThat(properties.getCache().getInitialCapacity()).isEqualTo(5); + } + + } + + @Nested + class ConsumerPropertiesTests { + + @Test + void bind() { + Map map = new HashMap<>(); + map.put("spring.pulsar.consumer.name", "my-consumer"); + map.put("spring.pulsar.consumer.subscription.initial-position", "earliest"); + map.put("spring.pulsar.consumer.subscription.mode", "nondurable"); + map.put("spring.pulsar.consumer.subscription.name", "my-subscription"); + map.put("spring.pulsar.consumer.subscription.topics-mode", "all-topics"); + map.put("spring.pulsar.consumer.subscription.type", "shared"); + map.put("spring.pulsar.consumer.topics[0]", "my-topic"); + map.put("spring.pulsar.consumer.topics-pattern", "my-pattern"); + map.put("spring.pulsar.consumer.priority-level", "8"); + map.put("spring.pulsar.consumer.read-compacted", "true"); + map.put("spring.pulsar.consumer.dead-letter-policy.max-redeliver-count", "4"); + map.put("spring.pulsar.consumer.dead-letter-policy.retry-letter-topic", "my-retry-topic"); + map.put("spring.pulsar.consumer.dead-letter-policy.dead-letter-topic", "my-dlt-topic"); + map.put("spring.pulsar.consumer.dead-letter-policy.initial-subscription-name", "my-initial-subscription"); + map.put("spring.pulsar.consumer.retry-enable", "true"); + PulsarProperties.Consumer properties = bindPropeties(map).getConsumer(); + assertThat(properties.getName()).isEqualTo("my-consumer"); + assertThat(properties.getSubscription()).satisfies((subscription) -> { + assertThat(subscription.getName()).isEqualTo("my-subscription"); + assertThat(subscription.getType()).isEqualTo(SubscriptionType.Shared); + assertThat(subscription.getMode()).isEqualTo(SubscriptionMode.NonDurable); + assertThat(subscription.getInitialPosition()).isEqualTo(SubscriptionInitialPosition.Earliest); + assertThat(subscription.getTopicsMode()).isEqualTo(RegexSubscriptionMode.AllTopics); + }); + assertThat(properties.getTopics()).containsExactly("my-topic"); + assertThat(properties.getTopicsPattern().toString()).isEqualTo("my-pattern"); + assertThat(properties.getPriorityLevel()).isEqualTo(8); + assertThat(properties.isReadCompacted()).isTrue(); + assertThat(properties.getDeadLetterPolicy()).satisfies((policy) -> { + assertThat(policy.getMaxRedeliverCount()).isEqualTo(4); + assertThat(policy.getRetryLetterTopic()).isEqualTo("my-retry-topic"); + assertThat(policy.getDeadLetterTopic()).isEqualTo("my-dlt-topic"); + assertThat(policy.getInitialSubscriptionName()).isEqualTo("my-initial-subscription"); + }); + assertThat(properties.isRetryEnable()).isTrue(); + } + + } + + @Nested + class ListenerProperties { + + @Test + void bind() { + Map map = new HashMap<>(); + map.put("spring.pulsar.listener.schema-type", "avro"); + map.put("spring.pulsar.listener.observation-enabled", "false"); + PulsarProperties.Listener properties = bindPropeties(map).getListener(); + assertThat(properties.getSchemaType()).isEqualTo(SchemaType.AVRO); + assertThat(properties.isObservationEnabled()).isFalse(); + } + + } + + @Nested + class ReaderProperties { + + @Test + void bind() { + Map map = new HashMap<>(); + map.put("spring.pulsar.reader.name", "my-reader"); + map.put("spring.pulsar.reader.topics", "my-topic"); + map.put("spring.pulsar.reader.subscription-name", "my-subscription"); + map.put("spring.pulsar.reader.subscription-role-prefix", "sub-role"); + map.put("spring.pulsar.reader.read-compacted", "true"); + PulsarProperties.Reader properties = bindPropeties(map).getReader(); + assertThat(properties.getName()).isEqualTo("my-reader"); + assertThat(properties.getTopics()).containsExactly("my-topic"); + assertThat(properties.getSubscriptionName()).isEqualTo("my-subscription"); + assertThat(properties.getSubscriptionRolePrefix()).isEqualTo("sub-role"); + assertThat(properties.isReadCompacted()).isTrue(); + } + + } + + @Nested + class TemplateProperties { + + @Test + void bind() { + Map map = new HashMap<>(); + map.put("spring.pulsar.template.observations-enabled", "false"); + PulsarProperties.Template properties = bindPropeties(map).getTemplate(); + assertThat(properties.isObservationsEnabled()).isFalse(); + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarReactiveAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarReactiveAutoConfigurationTests.java new file mode 100644 index 0000000000..4f3ab011ea --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarReactiveAutoConfigurationTests.java @@ -0,0 +1,473 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.pulsar; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; + +import com.github.benmanes.caffeine.cache.Caffeine; +import org.apache.pulsar.client.api.PulsarClient; +import org.apache.pulsar.common.schema.SchemaType; +import org.apache.pulsar.reactive.client.adapter.ProducerCacheProvider; +import org.apache.pulsar.reactive.client.api.ReactiveMessageConsumerBuilder; +import org.apache.pulsar.reactive.client.api.ReactiveMessageReaderBuilder; +import org.apache.pulsar.reactive.client.api.ReactiveMessageSenderBuilder; +import org.apache.pulsar.reactive.client.api.ReactiveMessageSenderCache; +import org.apache.pulsar.reactive.client.api.ReactivePulsarClient; +import org.apache.pulsar.reactive.client.producercache.CaffeineShadedProducerCacheProvider; +import org.assertj.core.api.AbstractObjectAssert; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.context.assertj.AssertableApplicationContext; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.core.annotation.Order; +import org.springframework.pulsar.core.DefaultSchemaResolver; +import org.springframework.pulsar.core.DefaultTopicResolver; +import org.springframework.pulsar.core.PulsarAdministration; +import org.springframework.pulsar.core.SchemaResolver; +import org.springframework.pulsar.core.TopicResolver; +import org.springframework.pulsar.reactive.config.DefaultReactivePulsarListenerContainerFactory; +import org.springframework.pulsar.reactive.config.ReactivePulsarListenerContainerFactory; +import org.springframework.pulsar.reactive.config.ReactivePulsarListenerEndpointRegistry; +import org.springframework.pulsar.reactive.config.annotation.ReactivePulsarBootstrapConfiguration; +import org.springframework.pulsar.reactive.config.annotation.ReactivePulsarListenerAnnotationBeanPostProcessor; +import org.springframework.pulsar.reactive.core.DefaultReactivePulsarConsumerFactory; +import org.springframework.pulsar.reactive.core.DefaultReactivePulsarReaderFactory; +import org.springframework.pulsar.reactive.core.DefaultReactivePulsarSenderFactory; +import org.springframework.pulsar.reactive.core.ReactiveMessageConsumerBuilderCustomizer; +import org.springframework.pulsar.reactive.core.ReactiveMessageReaderBuilderCustomizer; +import org.springframework.pulsar.reactive.core.ReactiveMessageSenderBuilderCustomizer; +import org.springframework.pulsar.reactive.core.ReactivePulsarConsumerFactory; +import org.springframework.pulsar.reactive.core.ReactivePulsarReaderFactory; +import org.springframework.pulsar.reactive.core.ReactivePulsarSenderFactory; +import org.springframework.pulsar.reactive.core.ReactivePulsarTemplate; +import org.springframework.pulsar.reactive.listener.ReactivePulsarContainerProperties; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link PulsarReactiveAutoConfiguration}. + * + * @author Chris Bono + * @author Christophe Bornet + * @author Phillip Webb + */ +class PulsarReactiveAutoConfigurationTests { + + private static final String INTERNAL_PULSAR_LISTENER_ANNOTATION_PROCESSOR = "org.springframework.pulsar.config.internalReactivePulsarListenerAnnotationProcessor"; + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(PulsarReactiveAutoConfiguration.class)) + .withBean(PulsarClient.class, () -> mock(PulsarClient.class)); + + @Test + void whenPulsarNotOnClasspathAutoConfigurationIsSkipped() { + new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(PulsarReactiveAutoConfiguration.class)) + .withClassLoader(new FilteredClassLoader(PulsarClient.class)) + .run((context) -> assertThat(context).doesNotHaveBean(PulsarReactiveAutoConfiguration.class)); + } + + @Test + void whenReactivePulsarNotOnClasspathAutoConfigurationIsSkipped() { + this.contextRunner.withClassLoader(new FilteredClassLoader(ReactivePulsarClient.class)) + .run((context) -> assertThat(context).doesNotHaveBean(PulsarReactiveAutoConfiguration.class)); + } + + @Test + void whenReactiveSpringPulsarNotOnClasspathAutoConfigurationIsSkipped() { + this.contextRunner.withClassLoader(new FilteredClassLoader(ReactivePulsarTemplate.class)) + .run((context) -> assertThat(context).doesNotHaveBean(PulsarReactiveAutoConfiguration.class)); + } + + @Test + void whenCustomPulsarListenerAnnotationProcessorDefinedAutoConfigurationIsSkipped() { + this.contextRunner.withBean(INTERNAL_PULSAR_LISTENER_ANNOTATION_PROCESSOR, String.class, () -> "bean") + .run((context) -> assertThat(context).doesNotHaveBean(ReactivePulsarBootstrapConfiguration.class)); + } + + @Test + void autoConfiguresBeans() { + this.contextRunner.run((context) -> assertThat(context).hasSingleBean(PulsarConfiguration.class) + .hasSingleBean(PulsarClient.class) + .hasSingleBean(PulsarAdministration.class) + .hasSingleBean(DefaultSchemaResolver.class) + .hasSingleBean(DefaultTopicResolver.class) + .hasSingleBean(ReactivePulsarClient.class) + .hasSingleBean(CaffeineShadedProducerCacheProvider.class) + .hasSingleBean(ReactiveMessageSenderCache.class) + .hasSingleBean(DefaultReactivePulsarSenderFactory.class) + .hasSingleBean(ReactivePulsarTemplate.class) + .hasSingleBean(DefaultReactivePulsarConsumerFactory.class) + .hasSingleBean(DefaultReactivePulsarListenerContainerFactory.class) + .hasSingleBean(ReactivePulsarListenerAnnotationBeanPostProcessor.class) + .hasSingleBean(ReactivePulsarListenerEndpointRegistry.class)); + } + + @Test + @SuppressWarnings("rawtypes") + void injectsExpectedBeansIntoReactivePulsarClient() { + this.contextRunner.run((context) -> { + PulsarClient pulsarClient = context.getBean(PulsarClient.class); + assertThat(context).hasNotFailed() + .getBean(ReactivePulsarClient.class) + .extracting("reactivePulsarResourceAdapter") + .extracting("pulsarClientSupplier", InstanceOfAssertFactories.type(Supplier.class)) + .extracting(Supplier::get) + .isSameAs(pulsarClient); + }); + } + + @ParameterizedTest + @ValueSource(classes = { ReactivePulsarClient.class, ProducerCacheProvider.class, ReactiveMessageSenderCache.class, + ReactivePulsarSenderFactory.class, ReactivePulsarConsumerFactory.class, ReactivePulsarReaderFactory.class, + ReactivePulsarTemplate.class }) + void whenHasUserDefinedBeanDoesNotAutoConfigureBean(Class beanClass) { + T bean = mock(beanClass); + this.contextRunner.withBean(beanClass.getName(), beanClass, () -> bean) + .run((context) -> assertThat(context).getBean(beanClass).isSameAs(bean)); + } + + @Nested + class SenderFactoryTests { + + private final ApplicationContextRunner contextRunner = PulsarReactiveAutoConfigurationTests.this.contextRunner; + + @Test + void injectsExpectedBeans() { + ReactivePulsarClient client = mock(ReactivePulsarClient.class); + ReactiveMessageSenderCache cache = mock(ReactiveMessageSenderCache.class); + this.contextRunner.withPropertyValues("spring.pulsar.producer.topic-name=test-topic") + .withBean("customReactivePulsarClient", ReactivePulsarClient.class, () -> client) + .withBean("customReactiveMessageSenderCache", ReactiveMessageSenderCache.class, () -> cache) + .run((context) -> { + DefaultReactivePulsarSenderFactory senderFactory = context + .getBean(DefaultReactivePulsarSenderFactory.class); + assertThat(senderFactory) + .extracting("reactivePulsarClient", InstanceOfAssertFactories.type(ReactivePulsarClient.class)) + .isSameAs(client); + assertThat(senderFactory) + .extracting("reactiveMessageSenderCache", + InstanceOfAssertFactories.type(ReactiveMessageSenderCache.class)) + .isSameAs(cache); + assertThat(senderFactory) + .extracting("topicResolver", InstanceOfAssertFactories.type(TopicResolver.class)) + .isSameAs(context.getBean(TopicResolver.class)); + }); + } + + @Test + void injectsExpectedBeansIntoReactiveMessageSenderCache() { + ProducerCacheProvider provider = mock(ProducerCacheProvider.class); + this.contextRunner.withBean("customProducerCacheProvider", ProducerCacheProvider.class, () -> provider) + .run((context) -> assertThat(context).getBean(ReactiveMessageSenderCache.class) + .extracting("cacheProvider", InstanceOfAssertFactories.type(ProducerCacheProvider.class)) + .isSameAs(provider)); + } + + @Test + void whenHasUserDefinedCustomizersAppliesInCorrectOrder() { + this.contextRunner.withPropertyValues("spring.pulsar.producer.name=fromPropsCustomizer") + .withUserConfiguration(ReactiveMessageSenderBuilderCustomizerConfig.class) + .run((context) -> { + DefaultReactivePulsarSenderFactory producerFactory = context + .getBean(DefaultReactivePulsarSenderFactory.class); + Customizers, ReactiveMessageSenderBuilder> customizers = Customizers + .of(ReactiveMessageSenderBuilder.class, ReactiveMessageSenderBuilderCustomizer::customize); + assertThat(customizers.fromField(producerFactory, "defaultConfigCustomizers")).callsInOrder( + ReactiveMessageSenderBuilder::producerName, "fromPropsCustomizer", "fromCustomizer1", + "fromCustomizer2"); + }); + } + + @TestConfiguration(proxyBeanMethods = false) + static class ReactiveMessageSenderBuilderCustomizerConfig { + + @Bean + @Order(200) + ReactiveMessageSenderBuilderCustomizer customizerFoo() { + return (builder) -> builder.producerName("fromCustomizer2"); + } + + @Bean + @Order(100) + ReactiveMessageSenderBuilderCustomizer customizerBar() { + return (builder) -> builder.producerName("fromCustomizer1"); + } + + } + + } + + @Nested + class TemplateTests { + + private final ApplicationContextRunner contextRunner = PulsarReactiveAutoConfigurationTests.this.contextRunner; + + @Test + @SuppressWarnings("rawtypes") + void injectsExpectedBeans() { + ReactivePulsarSenderFactory senderFactory = mock(ReactivePulsarSenderFactory.class); + SchemaResolver schemaResolver = mock(SchemaResolver.class); + this.contextRunner + .withBean("customReactivePulsarSenderFactory", ReactivePulsarSenderFactory.class, () -> senderFactory) + .withBean("schemaResolver", SchemaResolver.class, () -> schemaResolver) + .run((context) -> assertThat(context).getBean(ReactivePulsarTemplate.class).satisfies((template) -> { + assertThat(template).extracting("reactiveMessageSenderFactory").isSameAs(senderFactory); + assertThat(template).extracting("schemaResolver").isSameAs(schemaResolver); + })); + } + + } + + @Nested + class ConsumerFactoryTests { + + private final ApplicationContextRunner contextRunner = PulsarReactiveAutoConfigurationTests.this.contextRunner; + + @Test + void injectsExpectedBeans() { + ReactivePulsarClient client = mock(ReactivePulsarClient.class); + this.contextRunner.withBean("customReactivePulsarClient", ReactivePulsarClient.class, () -> client) + .run((context) -> { + ReactivePulsarConsumerFactory consumerFactory = context + .getBean(DefaultReactivePulsarConsumerFactory.class); + assertThat(consumerFactory) + .extracting("reactivePulsarClient", InstanceOfAssertFactories.type(ReactivePulsarClient.class)) + .isSameAs(client); + }); + } + + @Test + void whenHasUserDefinedCustomizersAppliesInCorrectOrder() { + this.contextRunner.withPropertyValues("spring.pulsar.consumer.name=fromPropsCustomizer") + .withUserConfiguration(ReactiveMessageConsumerBuilderCustomizerConfig.class) + .run((context) -> { + DefaultReactivePulsarConsumerFactory consumerFactory = context + .getBean(DefaultReactivePulsarConsumerFactory.class); + Customizers, ReactiveMessageConsumerBuilder> customizers = Customizers + .of(ReactiveMessageConsumerBuilder.class, ReactiveMessageConsumerBuilderCustomizer::customize); + assertThat(customizers.fromField(consumerFactory, "defaultConfigCustomizers")).callsInOrder( + ReactiveMessageConsumerBuilder::consumerName, "fromPropsCustomizer", "fromCustomizer1", + "fromCustomizer2"); + }); + } + + @TestConfiguration(proxyBeanMethods = false) + static class ReactiveMessageConsumerBuilderCustomizerConfig { + + @Bean + @Order(200) + ReactiveMessageConsumerBuilderCustomizer customizerFoo() { + return (builder) -> builder.consumerName("fromCustomizer2"); + } + + @Bean + @Order(100) + ReactiveMessageConsumerBuilderCustomizer customizerBar() { + return (builder) -> builder.consumerName("fromCustomizer1"); + } + + } + + } + + @Nested + class ListenerTests { + + private final ApplicationContextRunner contextRunner = PulsarReactiveAutoConfigurationTests.this.contextRunner; + + @Test + void whenHasUserDefinedBeanDoesNotAutoConfigureBean() { + ReactivePulsarListenerContainerFactory listenerContainerFactory = mock( + ReactivePulsarListenerContainerFactory.class); + this.contextRunner + .withBean("reactivePulsarListenerContainerFactory", ReactivePulsarListenerContainerFactory.class, + () -> listenerContainerFactory) + .run((context) -> assertThat(context).getBean(ReactivePulsarListenerContainerFactory.class) + .isSameAs(listenerContainerFactory)); + } + + @Test + void whenHasUserDefinedReactivePulsarListenerAnnotationBeanPostProcessorDoesNotAutoConfigureBean() { + ReactivePulsarListenerAnnotationBeanPostProcessor listenerAnnotationBeanPostProcessor = mock( + ReactivePulsarListenerAnnotationBeanPostProcessor.class); + this.contextRunner.withBean(INTERNAL_PULSAR_LISTENER_ANNOTATION_PROCESSOR, + ReactivePulsarListenerAnnotationBeanPostProcessor.class, () -> listenerAnnotationBeanPostProcessor) + .run((context) -> assertThat(context).getBean(ReactivePulsarListenerAnnotationBeanPostProcessor.class) + .isSameAs(listenerAnnotationBeanPostProcessor)); + } + + @Test + void whenHasCustomProperties() { + List properties = new ArrayList<>(); + properties.add("spring.pulsar.listener.schema-type=avro"); + this.contextRunner.withPropertyValues(properties.toArray(String[]::new)).run((context) -> { + DefaultReactivePulsarListenerContainerFactory factory = context + .getBean(DefaultReactivePulsarListenerContainerFactory.class); + assertThat(factory.getContainerProperties().getSchemaType()).isEqualTo(SchemaType.AVRO); + }); + } + + @Test + void injectsExpectedBeans() { + ReactivePulsarConsumerFactory consumerFactory = mock(ReactivePulsarConsumerFactory.class); + SchemaResolver schemaResolver = mock(SchemaResolver.class); + this.contextRunner + .withBean("customReactivePulsarConsumerFactory", ReactivePulsarConsumerFactory.class, + () -> consumerFactory) + .withBean("schemaResolver", SchemaResolver.class, () -> schemaResolver) + .run((context) -> { + DefaultReactivePulsarListenerContainerFactory containerFactory = context + .getBean(DefaultReactivePulsarListenerContainerFactory.class); + assertThat(containerFactory).extracting("consumerFactory").isSameAs(consumerFactory); + assertThat(containerFactory) + .extracting(DefaultReactivePulsarListenerContainerFactory::getContainerProperties) + .extracting(ReactivePulsarContainerProperties::getSchemaResolver) + .isSameAs(schemaResolver); + }); + } + + } + + @Nested + class ReaderFactoryTests { + + private final ApplicationContextRunner contextRunner = PulsarReactiveAutoConfigurationTests.this.contextRunner; + + @Test + void injectsExpectedBeans() { + ReactivePulsarClient client = mock(ReactivePulsarClient.class); + this.contextRunner.withPropertyValues("spring.pulsar.reader.name=test-reader") + .withBean("customReactivePulsarClient", ReactivePulsarClient.class, () -> client) + .run((context) -> { + DefaultReactivePulsarReaderFactory readerFactory = context + .getBean(DefaultReactivePulsarReaderFactory.class); + assertThat(readerFactory) + .extracting("reactivePulsarClient", InstanceOfAssertFactories.type(ReactivePulsarClient.class)) + .isSameAs(client); + }); + } + + @Test + void whenHasUserDefinedCustomizersAppliesInCorrectOrder() { + this.contextRunner.withPropertyValues("spring.pulsar.reader.name=fromPropsCustomizer") + .withUserConfiguration(ReactiveMessageReaderBuilderCustomizerConfig.class) + .run((context) -> { + DefaultReactivePulsarReaderFactory readerFactory = context + .getBean(DefaultReactivePulsarReaderFactory.class); + Customizers, ReactiveMessageReaderBuilder> customizers = Customizers + .of(ReactiveMessageReaderBuilder.class, ReactiveMessageReaderBuilderCustomizer::customize); + assertThat(customizers.fromField(readerFactory, "defaultConfigCustomizers")).callsInOrder( + ReactiveMessageReaderBuilder::readerName, "fromPropsCustomizer", "fromCustomizer1", + "fromCustomizer2"); + }); + } + + @TestConfiguration(proxyBeanMethods = false) + static class ReactiveMessageReaderBuilderCustomizerConfig { + + @Bean + @Order(200) + ReactiveMessageReaderBuilderCustomizer customizerFoo() { + return (builder) -> builder.readerName("fromCustomizer2"); + } + + @Bean + @Order(100) + ReactiveMessageReaderBuilderCustomizer customizerBar() { + return (builder) -> builder.readerName("fromCustomizer1"); + } + + } + + } + + @Nested + class SenderCacheAutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = PulsarReactiveAutoConfigurationTests.this.contextRunner; + + @Test + void whenNoPropertiesEnablesCaching() { + this.contextRunner.run(this::assertCaffeineProducerCacheProvider); + } + + @Test + void whenCachingEnabledEnablesCaching() { + this.contextRunner.withPropertyValues("spring.pulsar.producer.cache.enabled=true") + .run(this::assertCaffeineProducerCacheProvider); + } + + @Test + void whenCachingDisabledDoesNotEnableCaching() { + this.contextRunner.withPropertyValues("spring.pulsar.producer.cache.enabled=false") + .run((context) -> assertThat(context).doesNotHaveBean(ProducerCacheProvider.class) + .doesNotHaveBean(ReactiveMessageSenderCache.class)); + } + + @Test + void whenCachingEnabledAndCaffeineNotOnClasspathStillUsesCaffeine() { + // The reactive client shades Caffeine - it should still be used + this.contextRunner.withClassLoader(new FilteredClassLoader(Caffeine.class)) + .withPropertyValues("spring.pulsar.producer.cache.enabled=true") + .run(this::assertCaffeineProducerCacheProvider); + } + + @Test + void whenCachingEnabledAndNoCacheProviderAvailable() { + // The reactive client uses a shaded caffeine cache provider as its internal + // cache + this.contextRunner.withClassLoader(new FilteredClassLoader(CaffeineShadedProducerCacheProvider.class)) + .withPropertyValues("spring.pulsar.producer.cache.enabled=true") + .run((context) -> assertThat(context).doesNotHaveBean(ProducerCacheProvider.class) + .getBean(ReactiveMessageSenderCache.class) + .extracting("cacheProvider") + .isExactlyInstanceOf(CaffeineShadedProducerCacheProvider.class)); + } + + @Test + void whenCustomCachingPropertiesCreatesConfiguredBean() { + this.contextRunner + .withPropertyValues("spring.pulsar.producer.cache.expire-after-access=100s", + "spring.pulsar.producer.cache.maximum-size=5150", + "spring.pulsar.producer.cache.initial-capacity=200") + .run((context) -> assertCaffeineProducerCacheProvider(context).extracting("cache.cache") + .hasFieldOrPropertyWithValue("expiresAfterAccessNanos", Duration.ofSeconds(100).toNanos()) + .hasFieldOrPropertyWithValue("maximum", 5150L)); + } + + private AbstractObjectAssert assertCaffeineProducerCacheProvider( + AssertableApplicationContext context) { + return assertThat(context).hasSingleBean(ReactiveMessageSenderCache.class) + .getBean(ProducerCacheProvider.class) + .isExactlyInstanceOf(CaffeineShadedProducerCacheProvider.class); + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarReactivePropertiesMapperTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarReactivePropertiesMapperTests.java new file mode 100644 index 0000000000..df078b21a3 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/pulsar/PulsarReactivePropertiesMapperTests.java @@ -0,0 +1,146 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.pulsar; + +import java.time.Duration; +import java.util.List; +import java.util.regex.Pattern; + +import org.apache.pulsar.client.api.CompressionType; +import org.apache.pulsar.client.api.DeadLetterPolicy; +import org.apache.pulsar.client.api.HashingScheme; +import org.apache.pulsar.client.api.MessageRoutingMode; +import org.apache.pulsar.client.api.ProducerAccessMode; +import org.apache.pulsar.client.api.RegexSubscriptionMode; +import org.apache.pulsar.client.api.SubscriptionInitialPosition; +import org.apache.pulsar.client.api.SubscriptionMode; +import org.apache.pulsar.client.api.SubscriptionType; +import org.apache.pulsar.common.schema.SchemaType; +import org.apache.pulsar.reactive.client.api.ReactiveMessageConsumerBuilder; +import org.apache.pulsar.reactive.client.api.ReactiveMessageReaderBuilder; +import org.apache.pulsar.reactive.client.api.ReactiveMessageSenderBuilder; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.pulsar.PulsarProperties.Consumer; +import org.springframework.boot.autoconfigure.pulsar.PulsarProperties.Consumer.Subscription; +import org.springframework.pulsar.reactive.listener.ReactivePulsarContainerProperties; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link PulsarReactivePropertiesMapper}. + * + * @author Chris Bono + * @author Phillip Webb + */ +class PulsarReactivePropertiesMapperTests { + + @Test + @SuppressWarnings("unchecked") + void customizeMessageSenderBuilder() { + PulsarProperties properties = new PulsarProperties(); + properties.getProducer().setName("name"); + properties.getProducer().setTopicName("topicname"); + properties.getProducer().setSendTimeout(Duration.ofSeconds(1)); + properties.getProducer().setMessageRoutingMode(MessageRoutingMode.RoundRobinPartition); + properties.getProducer().setHashingScheme(HashingScheme.JavaStringHash); + properties.getProducer().setBatchingEnabled(false); + properties.getProducer().setChunkingEnabled(true); + properties.getProducer().setCompressionType(CompressionType.SNAPPY); + properties.getProducer().setAccessMode(ProducerAccessMode.Exclusive); + ReactiveMessageSenderBuilder builder = mock(ReactiveMessageSenderBuilder.class); + new PulsarReactivePropertiesMapper(properties).customizeMessageSenderBuilder(builder); + then(builder).should().producerName("name"); + then(builder).should().topic("topicname"); + then(builder).should().sendTimeout(Duration.ofSeconds(1)); + then(builder).should().messageRoutingMode(MessageRoutingMode.RoundRobinPartition); + then(builder).should().hashingScheme(HashingScheme.JavaStringHash); + then(builder).should().batchingEnabled(false); + then(builder).should().chunkingEnabled(true); + then(builder).should().compressionType(CompressionType.SNAPPY); + then(builder).should().accessMode(ProducerAccessMode.Exclusive); + } + + @Test + @SuppressWarnings("unchecked") + void customizeMessageConsumerBuilder() { + PulsarProperties properties = new PulsarProperties(); + List topics = List.of("mytopic"); + Pattern topisPattern = Pattern.compile("my-pattern"); + properties.getConsumer().setName("name"); + properties.getConsumer().setTopics(topics); + properties.getConsumer().setTopicsPattern(topisPattern); + properties.getConsumer().setPriorityLevel(123); + properties.getConsumer().setReadCompacted(true); + Consumer.DeadLetterPolicy deadLetterPolicy = new Consumer.DeadLetterPolicy(); + deadLetterPolicy.setDeadLetterTopic("my-dlt"); + deadLetterPolicy.setMaxRedeliverCount(1); + properties.getConsumer().setDeadLetterPolicy(deadLetterPolicy); + properties.getConsumer().setRetryEnable(false); + Subscription subscriptionProperties = properties.getConsumer().getSubscription(); + subscriptionProperties.setName("subname"); + subscriptionProperties.setInitialPosition(SubscriptionInitialPosition.Earliest); + subscriptionProperties.setMode(SubscriptionMode.NonDurable); + subscriptionProperties.setTopicsMode(RegexSubscriptionMode.NonPersistentOnly); + subscriptionProperties.setType(SubscriptionType.Key_Shared); + ReactiveMessageConsumerBuilder builder = mock(ReactiveMessageConsumerBuilder.class); + new PulsarReactivePropertiesMapper(properties).customizeMessageConsumerBuilder(builder); + then(builder).should().consumerName("name"); + then(builder).should().topics(topics); + then(builder).should().topicsPattern(topisPattern); + then(builder).should().priorityLevel(123); + then(builder).should().readCompacted(true); + then(builder).should().deadLetterPolicy(new DeadLetterPolicy(1, null, "my-dlt", null)); + then(builder).should().retryLetterTopicEnable(false); + then(builder).should().subscriptionName("subname"); + then(builder).should().subscriptionInitialPosition(SubscriptionInitialPosition.Earliest); + then(builder).should().subscriptionMode(SubscriptionMode.NonDurable); + then(builder).should().topicsPatternSubscriptionMode(RegexSubscriptionMode.NonPersistentOnly); + then(builder).should().subscriptionType(SubscriptionType.Key_Shared); + } + + @Test + void customizeContainerProperties() { + PulsarProperties properties = new PulsarProperties(); + properties.getConsumer().getSubscription().setType(SubscriptionType.Shared); + properties.getListener().setSchemaType(SchemaType.AVRO); + ReactivePulsarContainerProperties containerProperties = new ReactivePulsarContainerProperties<>(); + new PulsarReactivePropertiesMapper(properties).customizeContainerProperties(containerProperties); + assertThat(containerProperties.getSubscriptionType()).isEqualTo(SubscriptionType.Shared); + assertThat(containerProperties.getSchemaType()).isEqualTo(SchemaType.AVRO); + } + + @Test + @SuppressWarnings("unchecked") + void customizeMessageReaderBuilder() { + List topics = List.of("my-topic"); + PulsarProperties properties = new PulsarProperties(); + properties.getReader().setName("name"); + properties.getReader().setTopics(topics); + properties.getReader().setSubscriptionName("subname"); + properties.getReader().setSubscriptionRolePrefix("srp"); + ReactiveMessageReaderBuilder builder = mock(ReactiveMessageReaderBuilder.class); + new PulsarReactivePropertiesMapper(properties).customizeMessageReaderBuilder(builder); + then(builder).should().readerName("name"); + then(builder).should().topics(topics); + then(builder).should().subscriptionName("subname"); + then(builder).should().generatedSubscriptionNamePrefix("srp"); + } + +} diff --git a/spring-boot-project/spring-boot-dependencies/build.gradle b/spring-boot-project/spring-boot-dependencies/build.gradle index f143017261..9fd61d6a04 100644 --- a/spring-boot-project/spring-boot-dependencies/build.gradle +++ b/spring-boot-project/spring-boot-dependencies/build.gradle @@ -1128,6 +1128,96 @@ bom { ] } } + library("Pulsar", "3.1.0") { + group("org.apache.pulsar") { + modules = [ + "bouncy-castle-bc", + "bouncy-castle-bcfips", + "pulsar-client-1x-base", + "pulsar-client-1x", + "pulsar-client-2x-shaded", + "pulsar-client-admin-api", + "pulsar-client-admin-original", + "pulsar-client-admin", + "pulsar-client-all", + "pulsar-client-api", + "pulsar-client-auth-athenz", + "pulsar-client-auth-sasl", + "pulsar-client-messagecrypto-bc", + "pulsar-client-original", + "pulsar-client-tools-api", + "pulsar-client-tools", + "pulsar-client", + "pulsar-common", + "pulsar-config-validation", + "pulsar-functions-api", + "pulsar-functions-proto", + "pulsar-functions-utils", + "pulsar-io-aerospike", + "pulsar-io-alluxio", + "pulsar-io-aws", + "pulsar-io-batch-data-generator", + "pulsar-io-batch-discovery-triggerers", + "pulsar-io-canal", + "pulsar-io-cassandra", + "pulsar-io-common", + "pulsar-io-core", + "pulsar-io-data-generator", + "pulsar-io-debezium-core", + "pulsar-io-debezium-mongodb", + "pulsar-io-debezium-mssql", + "pulsar-io-debezium-mysql", + "pulsar-io-debezium-oracle", + "pulsar-io-debezium-postgres", + "pulsar-io-debezium", + "pulsar-io-dynamodb", + "pulsar-io-elastic-search", + "pulsar-io-file", + "pulsar-io-flume", + "pulsar-io-hbase", + "pulsar-io-hdfs2", + "pulsar-io-hdfs3", + "pulsar-io-http", + "pulsar-io-influxdb", + "pulsar-io-jdbc-clickhouse", + "pulsar-io-jdbc-core", + "pulsar-io-jdbc-mariadb", + "pulsar-io-jdbc-openmldb", + "pulsar-io-jdbc-postgres", + "pulsar-io-jdbc-sqlite", + "pulsar-io-jdbc", + "pulsar-io-kafka-connect-adaptor-nar", + "pulsar-io-kafka-connect-adaptor", + "pulsar-io-kafka", + "pulsar-io-kinesis", + "pulsar-io-mongo", + "pulsar-io-netty", + "pulsar-io-nsq", + "pulsar-io-rabbitmq", + "pulsar-io-redis", + "pulsar-io-solr", + "pulsar-io-twitter", + "pulsar-io", + "pulsar-metadata", + "pulsar-presto-connector-original", + "pulsar-presto-connector", + "pulsar-sql", + "pulsar-transaction-common", + "pulsar-websocket" + ] + } + } + library("Pulsar Reactive", "0.3.0") { + group("org.apache.pulsar") { + modules = [ + "pulsar-client-reactive-adapter", + "pulsar-client-reactive-api", + "pulsar-client-reactive-jackson", + "pulsar-client-reactive-producer-cache-caffeine-shaded", + "pulsar-client-reactive-producer-cache-caffeine" + ] + } + } library("Quartz", "2.3.2") { group("org.quartz-scheduler") { modules = [ @@ -1477,6 +1567,14 @@ bom { ] } } + library("Spring Pulsar", "1.0.0-SNAPSHOT") { + group("org.springframework.pulsar") { + modules = [ + "spring-pulsar", + "spring-pulsar-reactive" + ] + } + } library("Spring RESTDocs", "3.0.0") { considerSnapshots() group("org.springframework.restdocs") { diff --git a/spring-boot-project/spring-boot-docs/build.gradle b/spring-boot-project/spring-boot-docs/build.gradle index 750de80a6e..0a92048ce5 100644 --- a/spring-boot-project/spring-boot-docs/build.gradle +++ b/spring-boot-project/spring-boot-docs/build.gradle @@ -163,6 +163,8 @@ dependencies { implementation("org.springframework.graphql:spring-graphql-test") implementation("org.springframework.kafka:spring-kafka") implementation("org.springframework.kafka:spring-kafka-test") + implementation("org.springframework.pulsar:spring-pulsar") + implementation("org.springframework.pulsar:spring-pulsar-reactive") implementation("org.springframework.restdocs:spring-restdocs-mockmvc") implementation("org.springframework.restdocs:spring-restdocs-restassured") implementation("org.springframework.restdocs:spring-restdocs-webtestclient") @@ -336,6 +338,7 @@ tasks.withType(org.asciidoctor.gradle.jvm.AbstractAsciidoctorTask) { "spring-graphql-version": versionConstraints["org.springframework.graphql:spring-graphql"], "spring-integration-version": versionConstraints["org.springframework.integration:spring-integration-core"], "spring-kafka-version": versionConstraints["org.springframework.kafka:spring-kafka"], + "spring-pulsar-version": versionConstraints["org.springframework.pulsar:spring-pulsar"], "spring-security-version": securityVersion, "spring-authorization-server-version": versionConstraints["org.springframework.security:spring-security-oauth2-authorization-server"], "spring-webservices-version": versionConstraints["org.springframework.ws:spring-ws-core"], diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/attributes.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/attributes.adoc index f867c630e1..f8aab54bfd 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/attributes.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/attributes.adoc @@ -90,6 +90,7 @@ :spring-integration: https://spring.io/projects/spring-integration :spring-integration-docs: https://docs.spring.io/spring-integration/docs/{spring-integration-version}/reference/html/ :spring-kafka-docs: https://docs.spring.io/spring-kafka/docs/{spring-kafka-version}/reference/html/ +:spring-pulsar-docs: https://docs.spring.io/spring-pulsar/docs/{spring-pulsar-version}/reference/html/ :spring-restdocs: https://spring.io/projects/spring-restdocs :spring-security: https://spring.io/projects/spring-security :spring-security-docs: https://docs.spring.io/spring-security/reference/{spring-security-version} diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/documentation/messaging.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/documentation/messaging.adoc index 51412fde0c..d6ced6c277 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/documentation/messaging.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/documentation/messaging.adoc @@ -5,5 +5,6 @@ If your application uses any messaging protocol, see one or more of the followin * *JMS:* <> * *AMQP:* <> * *Kafka:* <> +* *Pulsar:* <> * *RSocket:* <> * *Spring Integration:* <> diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/ssl.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/ssl.adoc index 5f15b9720b..759755c25a 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/ssl.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/ssl.adoc @@ -104,4 +104,3 @@ In addition, the `SslBundle` provides details about the key being used, the prot The following example shows retrieving an `SslBundle` and using it to create an `SSLContext`: include::code:MyComponent[] - diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/index.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/index.adoc index 3d6604e85b..4966c197d1 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/index.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/index.adoc @@ -19,7 +19,7 @@ The reference documentation consists of the following sections: <> :: Servlet Web, Reactive Web, Embedded Container Support, Graceful Shutdown, and more. <> :: SQL and NOSQL data access. <> :: Caching, Quartz Scheduler, REST clients, Sending email, Spring Web Services, and more. -<> :: JMS, AMQP, Apache Kafka, RSocket, WebSocket, and Spring Integration. +<> :: JMS, AMQP, Apache Kafka, Apache Pulsar, RSocket, WebSocket, and Spring Integration. <> :: Efficient container images and Building container images with Dockerfiles and Cloud Native Buildpacks. <> :: Monitoring, Metrics, Auditing, and more. <> :: Deploying to the Cloud, and Installing as a Unix application. diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging.adoc index 8b6a5ec6e6..12aca393d1 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging.adoc @@ -6,7 +6,7 @@ The Spring Framework provides extensive support for integrating with messaging s Spring AMQP provides a similar feature set for the Advanced Message Queuing Protocol. Spring Boot also provides auto-configuration options for `RabbitTemplate` and RabbitMQ. Spring WebSocket natively includes support for STOMP messaging, and Spring Boot has support for that through starters and a small amount of auto-configuration. -Spring Boot also has support for Apache Kafka. +Spring Boot also has support for Apache Kafka and Apache Pulsar. include::messaging/jms.adoc[] @@ -15,6 +15,8 @@ include::messaging/amqp.adoc[] include::messaging/kafka.adoc[] +include::messaging/pulsar.adoc[] + include::messaging/rsocket.adoc[] include::messaging/spring-integration.adoc[] diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging/pulsar.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging/pulsar.adoc new file mode 100644 index 0000000000..8e3f78ebc8 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/messaging/pulsar.adoc @@ -0,0 +1,201 @@ +[[messaging.pulsar]] +== Apache Pulsar Support +https://pulsar.apache.org/[Apache Pulsar] is supported by providing auto-configuration of the {spring-pulsar-docs}[Spring for Apache Pulsar] project. + +Spring Boot will auto-configure and register the classic (imperative) Spring Pulsar components when `org.springframework.pulsar:spring-pulsar` is on the classpath. +It will do the same for the reactive components when `org.springframework.pulsar:spring-pulsar-reactive` is on the classpath. + +There are `spring-boot-starter-pulsar` and `spring-boot-starter-pulsar-reactive` "`Starters`" for conveniently collecting the dependencies for imperative and reactive use, respectively. + + + +[[messaging.pulsar.connecting]] +=== Connecting to Pulsar +When you use the Pulsar starter, Spring Boot will auto-configure and register a `PulsarClient` bean. + +By default, the application tries to connect to a local Pulsar instance at `pulsar://localhost:6650`. +This can be adjusted by setting the configprop:spring.pulsar.client.service-url[] property to a different value. + +NOTE: The value must be a valid https://pulsar.apache.org/docs/client-libraries-java/#connection-urls[Pulsar Protocol] URL + +You can configure the client by specifying any of the `spring.pulsar.client.*` prefixed application properties. + +If you need more control over the configuration, consider registering one or more `PulsarClientBuilderCustomizer` beans. + + + +[[messaging.pulsar.connecting.auth]] +==== Authentication +To connect to a Pulsar cluster that requires authentication, you need to specify which authentication plugin to use by setting the `authPluginClassName` and any parameters required by the plugin. +You can set the parameters as a map of parameter names to parameter values. +The following example shows how to configure the `AuthenticationOAuth2` plugin. + +[source,yaml,indent=0,subs="verbatim",configprops,configblocks] +---- +spring: + pulsar: + client: + authentication: + plugin-class-name: org.apache.pulsar.client.impl.auth.oauth2.AuthenticationOAuth2 + param: + issuerUrl: https://auth.server.cloud/ + privateKey: file:///Users/some-key.json + audience: urn:sn:acme:dev:my-instance +---- + +[NOTE] +==== +You need to ensure that names defined under `+spring.pulsar.client.authentication.param.*+` exactly match those expected by your auth plugin (which is typically camel cased). +Spring Boot will not attempt any kind of relaxed binding for these entries. + +For example, if you want to configure the issuer url for the `AuthenticationOAuth2` auth plugin you must use `+spring.pulsar.client.authentication.param.issuerUrl+`. +If you use other forms, such as `issuerurl` or `issuer-url`, the setting will not be applied to the plugin. +==== + +For complete details on the client and authentication see the Spring Pulsar {spring-pulsar-docs}#pulsar-client[reference documentation]. + + + +[[messaging.pulsar.connecting-reactive]] +=== Connecting to Pulsar Reactively +When the Reactive auto-configuration is activated, Spring Boot will auto-configure and register a `ReactivePulsarClient` bean. + +The `ReactivePulsarClient` adapts an instance of the previously described `PulsarClient`. +Therefore, follow the previous section to configure the `PulsarClient` used by the `ReactivePulsarClient`. + + + +[[messaging.pulsar.admin]] +=== Connecting to Pulsar Administration +Spring Pulsar's `PulsarAdministration` client is also auto-configured. + +By default, the application tries to connect to a local Pulsar instance at `\http://localhost:8080`. +This can be adjusted by setting the configprop:spring.pulsar.admin.service-url[] property to a different value in the form `(http|https)://:`. + +If you need more control over the configuration, consider registering one or more `PulsarAdminBuilderCustomizer` beans. + + +[[messaging.pulsar.admin.auth]] +==== Authentication +When accessing a Pulsar cluster that requires authentication, the admin client requires the same security configuration as the regular Pulsar client. +You can use the aforementioned <> by replacing `spring.pulsar.client.authentication` with `spring.pulsar.admin.authentication`. + +TIP: To create a topic on startup, add a bean of type `PulsarTopic`. +If the topic already exists, the bean is ignored. + + + +[[messaging.pulsar.sending]] +=== Sending a Message +Spring's `PulsarTemplate` is auto-configured, and you can use it to send messages, as shown in the following example: + +include::code:MyBean[] + +The `PulsarTemplate` relies on a `PulsarProducerFactory` to create the underlying Pulsar producer. +Spring Boot auto-configuration also provides this producer factory, which by default, caches the producers that it creates. +You can configure the producer factory and cache settings by specifying any of the `spring.pulsar.producer.\*` and `spring.pulsar.producer.cache.*` prefixed application properties. + +If you need more control over the producer factory configuration, consider registering one or more `ProducerBuilderCustomizer` beans. +These customizers are applied to all created producers. +You can also pass in a `ProducerBuilderCustomizer` when sending a message to only affect the current producer. + +If you need more control over the message being sent, you can pass in a `TypedMessageBuilderCustomizer` when sending a message. + + + +[[messaging.pulsar.sending-reactive]] +=== Sending a Message Reactively +When the Reactive auto-configuration is activated, Spring's `ReactivePulsarTemplate` is auto-configured, and you can use it to send messages, as shown in the following example: + +include::code:MyBean[] + +The `ReactivePulsarTemplate` relies on a `ReactivePulsarSenderFactory` to actually create the underlying sender. +Spring Boot auto-configuration also provides this sender factory, which by default, caches the producers that it creates. +You can configure the sender factory and cache settings by specifying any of the `spring.pulsar.producer.\*` and `spring.pulsar.producer.cache.*` prefixed application properties. + +If you need more control over the sender factory configuration, consider registering one or more `ReactiveMessageSenderBuilderCustomizer` beans. +These customizers are applied to all created senders. +You can also pass in a `ReactiveMessageSenderBuilderCustomizer` when sending a message to only affect the current sender. + +If you need more control over the message being sent, you can pass in a `MessageSpecBuilderCustomizer` when sending a message. + + + +[[messaging.pulsar.receiving]] +=== Receiving a Message +When the Apache Pulsar infrastructure is present, any bean can be annotated with `@PulsarListener` to create a listener endpoint. +The following component creates a listener endpoint on the `someTopic` topic: + +include::code:MyBean[] + +Spring Boot auto-configuration provides all the components necessary for `PulsarListener`, such as the `PulsarListenerContainerFactory` and the consumer factory it uses to construct the underlying Pulsar consumers. +You can configure these components by specifying any of the `spring.pulsar.listener.\*` and `spring.pulsar.consumer.*` prefixed application properties. + +If you need more control over the consumer factory configuration, consider registering one or more `ConsumerBuilderCustomizer` beans. +These customizers are applied to all consumers created by the factory, and therefore all `@PulsarListener` instances. +You can also customize a single listener by setting the `consumerCustomizer` attribute of the `@PulsarListener` annotation. + + + +[[messaging.pulsar.receiving-reactive]] +=== Receiving a Message Reactively +When the Apache Pulsar infrastructure is present and the Reactive auto-configuration is activated, any bean can be annotated with `@ReactivePulsarListener` to create a reactive listener endpoint. +The following component creates a reactive listener endpoint on the `someTopic` topic: + +include::code:MyBean[] + +Spring Boot auto-configuration provides all the components necessary for `ReactivePulsarListener`, such as the `ReactivePulsarListenerContainerFactory` and the consumer factory it uses to construct the underlying reactive Pulsar consumers. +You can configure these components by specifying any of the `spring.pulsar.listener.*` and `spring.pulsar.consumer.*` prefixed application properties. + +If you need more control over the consumer factory configuration, consider registering one or more `ReactiveMessageConsumerBuilderCustomizer` beans. +These customizers are applied to all consumers created by the factory, and therefore all `@ReactivePulsarListener` instances. +You can also customize a single listener by setting the `consumerCustomizer` attribute of the `@ReactivePulsarListener` annotation. + + + +[[messaging.pulsar.reading]] +=== Reading a Message +The Pulsar reader interface enables applications to manually manage cursors. +When you use a reader to connect to a topic you need to specify which message the reader begins reading from when it connects to a topic. + +When the Apache Pulsar infrastructure is present, any bean can be annotated with `@PulsarReader` to consume messages using a reader. +The following component creates a reader endpoint that starts reading messages from the beginning of the `someTopic` topic: + +include::code:MyBean[] + +The `@PulsarReader` relies on a `PulsarReaderFactory` to create the underlying Pulsar reader. +Spring Boot auto-configuration provides this reader factory which can be customized by setting any of the `spring.pulsar.reader.*` prefixed application properties. + +If you need more control over the reader factory configuration, consider registering one or more `ReaderBuilderCustomizer` beans. +These customizers are applied to all readers created by the factory, and therefore all `@PulsarReader` instances. +You can also customize a single listener by setting the `readerCustomizer` attribute of the `@PulsarReader` annotation. + + + +[[messaging.pulsar.reading-reactive]] +=== Reading a Message Reactively +When the Apache Pulsar infrastructure is present and the Reactive auto-configuration is activated, Spring's `ReactivePulsarReaderFactory` is provided, and you can use it to create a reader in order to read messages in a reactive fashion. +The following component creates a reader using the provided factory and reads a single message from 5 minutes ago from the `someTopic` topic: + +include::code:MyBean[] + +Spring Boot auto-configuration provides this reader factory which can be customized by setting any of the `spring.pulsar.reader.*` prefixed application properties. + +If you need more control over the reader factory configuration, consider passing in one or more `ReactiveMessageReaderBuilderCustomizer` instances when using the factory to create a reader. + +If you need more control over the reader factory configuration, consider registering one or more `ReactiveMessageReaderBuilderCustomizer` beans. +These customizers are applied to all created readers. +You can also pass one or more `ReactiveMessageReaderBuilderCustomizer` when creating a reader to only apply the customizations to the created reader. + +TIP: For more details on any of the above components and to discover other available features, see the Spring for Apache Pulsar {spring-pulsar-docs}[reference documentation]. + + + +[[messaging.pulsar.additional-properties]] +=== Additional Pulsar Properties +The properties supported by auto-configuration are shown in the <> section of the Appendix. +Note that, for the most part, these properties (hyphenated or camelCase) map directly to the Apache Pulsar configuration properties. +See the Apache Pulsar documentation for details. + +Only a subset of the properties supported by Pulsar are available directly through the `PulsarProperties` class. +If you wish to tune the auto-configured components with additional properties that are not directly supported, you can use the customizer supported by each aforementioned component. diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/reading/MyBean.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/reading/MyBean.java new file mode 100644 index 0000000000..f13cf6ec54 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/reading/MyBean.java @@ -0,0 +1,30 @@ +/* + * Copyright 2023-2023 the original author or authors. + * + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.docs.messaging.pulsar.reading; + +import org.springframework.pulsar.annotation.PulsarReader; +import org.springframework.stereotype.Component; + +@Component +public class MyBean { + + @PulsarReader(topics = "someTopic", startMessageId = "earliest") + public void processMessage(String content) { + // ... + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/readingreactive/MyBean.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/readingreactive/MyBean.java new file mode 100644 index 0000000000..c42145288d --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/readingreactive/MyBean.java @@ -0,0 +1,50 @@ +/* + * Copyright 2023-2023 the original author or authors. + * + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.docs.messaging.pulsar.readingreactive; + +import java.time.Instant; +import java.util.List; + +import org.apache.pulsar.client.api.Message; +import org.apache.pulsar.client.api.Schema; +import org.apache.pulsar.reactive.client.api.StartAtSpec; +import reactor.core.publisher.Mono; + +import org.springframework.pulsar.reactive.core.ReactiveMessageReaderBuilderCustomizer; +import org.springframework.pulsar.reactive.core.ReactivePulsarReaderFactory; +import org.springframework.stereotype.Component; + +@Component +public class MyBean { + + private final ReactivePulsarReaderFactory pulsarReaderFactory; + + public MyBean(ReactivePulsarReaderFactory pulsarReaderFactory) { + this.pulsarReaderFactory = pulsarReaderFactory; + } + + public void someMethod() { + ReactiveMessageReaderBuilderCustomizer readerBuilderCustomizer = (readerBuilder) -> readerBuilder + .topic("someTopic") + .startAtSpec(StartAtSpec.ofInstant(Instant.now().minusSeconds(5))); + Mono> message = this.pulsarReaderFactory + .createReader(Schema.STRING, List.of(readerBuilderCustomizer)) + .readOne(); + // ... + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/receiving/MyBean.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/receiving/MyBean.java new file mode 100644 index 0000000000..103e4ac8d6 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/receiving/MyBean.java @@ -0,0 +1,30 @@ +/* + * Copyright 2023-2023 the original author or authors. + * + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.docs.messaging.pulsar.receiving; + +import org.springframework.pulsar.annotation.PulsarListener; +import org.springframework.stereotype.Component; + +@Component +public class MyBean { + + @PulsarListener(topics = "someTopic") + public void processMessage(String content) { + // ... + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/receivingreactive/MyBean.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/receivingreactive/MyBean.java new file mode 100644 index 0000000000..3dd9e8ffba --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/receivingreactive/MyBean.java @@ -0,0 +1,33 @@ +/* + * Copyright 2023-2023 the original author or authors. + * + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.docs.messaging.pulsar.receivingreactive; + +import reactor.core.publisher.Mono; + +import org.springframework.pulsar.reactive.config.annotation.ReactivePulsarListener; +import org.springframework.stereotype.Component; + +@Component +public class MyBean { + + @ReactivePulsarListener(topics = "someTopic") + public Mono processMessage(String content) { + // ... + return Mono.empty(); + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/sending/MyBean.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/sending/MyBean.java new file mode 100644 index 0000000000..7b6610b03e --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/sending/MyBean.java @@ -0,0 +1,37 @@ +/* + * Copyright 2023-2023 the original author or authors. + * + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.docs.messaging.pulsar.sending; + +import org.apache.pulsar.client.api.PulsarClientException; + +import org.springframework.pulsar.core.PulsarTemplate; +import org.springframework.stereotype.Component; + +@Component +public class MyBean { + + private final PulsarTemplate pulsarTemplate; + + public MyBean(PulsarTemplate pulsarTemplate) { + this.pulsarTemplate = pulsarTemplate; + } + + public void someMethod() throws PulsarClientException { + this.pulsarTemplate.send("someTopic", "Hello"); + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/sendingreactive/MyBean.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/sendingreactive/MyBean.java new file mode 100644 index 0000000000..1784f4ea80 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/messaging/pulsar/sendingreactive/MyBean.java @@ -0,0 +1,35 @@ +/* + * Copyright 2023-2023 the original author or authors. + * + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.docs.messaging.pulsar.sendingreactive; + +import org.springframework.pulsar.reactive.core.ReactivePulsarTemplate; +import org.springframework.stereotype.Component; + +@Component +public class MyBean { + + private final ReactivePulsarTemplate pulsarTemplate; + + public MyBean(ReactivePulsarTemplate pulsarTemplate) { + this.pulsarTemplate = pulsarTemplate; + } + + public void someMethod() { + this.pulsarTemplate.send("someTopic", "Hello").subscribe(); + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/reading/MyBean.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/reading/MyBean.kt new file mode 100644 index 0000000000..bb2936cc07 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/reading/MyBean.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2012-2022 the original author or authors. + * + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.docs.messaging.pulsar.reading + +import org.springframework.pulsar.annotation.PulsarReader +import org.springframework.stereotype.Component + +@Suppress("UNUSED_PARAMETER") +@Component +class MyBean { + + @PulsarReader(topics = ["someTopic"], startMessageId = "earliest") + fun processMessage(content: String?) { + // ... + } + +} + diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/readingreactive/MyBean.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/readingreactive/MyBean.kt new file mode 100644 index 0000000000..7651be5581 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/readingreactive/MyBean.kt @@ -0,0 +1,44 @@ +/* +* Copyright 2023-2023 the original author or authors. +* +* 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 +* +* https://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package org.springframework.boot.docs.messaging.pulsar.readingreactive + +import org.apache.pulsar.client.api.Schema +import org.apache.pulsar.reactive.client.api.ReactiveMessageReaderBuilder +import org.apache.pulsar.reactive.client.api.StartAtSpec +import org.springframework.pulsar.reactive.core.ReactiveMessageReaderBuilderCustomizer +import org.springframework.pulsar.reactive.core.ReactivePulsarReaderFactory +import org.springframework.stereotype.Component +import java.time.Instant + +@Suppress("UNUSED_PARAMETER", "UNUSED_VARIABLE") +@Component +class MyBean(private val pulsarReaderFactory: ReactivePulsarReaderFactory) { + + fun someMethod() { + val readerBuilderCustomizer = ReactiveMessageReaderBuilderCustomizer { + readerBuilder: ReactiveMessageReaderBuilder -> + readerBuilder + .topic("someTopic") + .startAtSpec(StartAtSpec.ofInstant(Instant.now().minusSeconds(5))) + } + val message = pulsarReaderFactory + .createReader(Schema.STRING, listOf(readerBuilderCustomizer)) + .readOne() + // ... + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/receiving/MyBean.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/receiving/MyBean.kt new file mode 100644 index 0000000000..80ee6160ab --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/receiving/MyBean.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2012-2022 the original author or authors. + * + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.docs.messaging.pulsar.receiving + +import org.springframework.pulsar.annotation.PulsarListener +import org.springframework.stereotype.Component + +@Suppress("UNUSED_PARAMETER") +@Component +class MyBean { + + @PulsarListener(topics = ["someTopic"]) + fun processMessage(content: String?) { + // ... + } + +} + diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/receivingreactive/MyBean.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/receivingreactive/MyBean.kt new file mode 100644 index 0000000000..6434ff8492 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/receivingreactive/MyBean.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2023-2023 the original author or authors. + * + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.boot.docs.messaging.pulsar.receivingreactive + +import org.springframework.pulsar.reactive.config.annotation.ReactivePulsarListener +import org.springframework.stereotype.Component +import reactor.core.publisher.Mono + +@Component +@Suppress("UNUSED_PARAMETER") +class MyBean { + + @ReactivePulsarListener(topics = ["someTopic"]) + fun processMessage(content: String?): Mono { + // ... + return Mono.empty() + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/sending/MyBean.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/sending/MyBean.kt new file mode 100644 index 0000000000..9a94168c6c --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/sending/MyBean.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2012-2022 the original author or authors. + * + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.docs.messaging.pulsar.sending + +import org.apache.pulsar.client.api.PulsarClientException +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.pulsar.core.PulsarTemplate +import org.springframework.stereotype.Component + +@Component +class MyBean(private val pulsarTemplate: PulsarTemplate) { + + @Throws(PulsarClientException::class) + fun someMethod() { + pulsarTemplate.send("someTopic", "Hello") + } + +} + diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/sendingreactive/MyBean.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/sendingreactive/MyBean.kt new file mode 100644 index 0000000000..3205912919 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/messaging/pulsar/sendingreactive/MyBean.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2023-2023 the original author or authors. + * + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.boot.docs.messaging.pulsar.sendingreactive + +import org.springframework.pulsar.reactive.core.ReactivePulsarTemplate +import org.springframework.stereotype.Component + +@Component +class MyBean(private val pulsarTemplate: ReactivePulsarTemplate) { + + fun someMethod() { + pulsarTemplate.send("someTopic", "Hello").subscribe() + } + +} diff --git a/spring-boot-project/spring-boot-starters/spring-boot-starter-pulsar-reactive/build.gradle b/spring-boot-project/spring-boot-starters/spring-boot-starter-pulsar-reactive/build.gradle new file mode 100644 index 0000000000..777b69567b --- /dev/null +++ b/spring-boot-project/spring-boot-starters/spring-boot-starter-pulsar-reactive/build.gradle @@ -0,0 +1,19 @@ +plugins { + id "org.springframework.boot.starter" +} + +description = "Starter for using Spring for Apache Pulsar Reactive" + +dependencies { + api(project(":spring-boot-project:spring-boot-starters:spring-boot-starter")) + api("org.springframework.pulsar:spring-pulsar-reactive") +} + +checkRuntimeClasspathForConflicts { + ignore { name -> name.startsWith("org/bouncycastle/") || + name.matches("^org\\/apache\\/pulsar\\/.*\\/package-info.class\$") || + name.equals("findbugsExclude.xml") || + name.startsWith("org/springframework/pulsar/shade/com/github/benmanes/caffeine/") || + name.startsWith("org/springframework/pulsar/shade/com/google/errorprone/") || + name.startsWith("org/springframework/pulsar/shade/org/checkerframework/") } +} diff --git a/spring-boot-project/spring-boot-starters/spring-boot-starter-pulsar/build.gradle b/spring-boot-project/spring-boot-starters/spring-boot-starter-pulsar/build.gradle new file mode 100644 index 0000000000..87b4c4b628 --- /dev/null +++ b/spring-boot-project/spring-boot-starters/spring-boot-starter-pulsar/build.gradle @@ -0,0 +1,16 @@ +plugins { + id "org.springframework.boot.starter" +} + +description = "Starter for using Spring for Apache Pulsar" + +dependencies { + api(project(":spring-boot-project:spring-boot-starters:spring-boot-starter")) + api("org.springframework.pulsar:spring-pulsar") +} + +checkRuntimeClasspathForConflicts { + ignore { name -> name.startsWith("org/bouncycastle/") || + name.matches("^org\\/apache\\/pulsar\\/.*\\/package-info.class\$") || + name.equals("findbugsExclude.xml") } +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/testcontainers/DockerImageNames.java b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/testcontainers/DockerImageNames.java index 6c9eb457d7..780e594e1a 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/testcontainers/DockerImageNames.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-test-support/src/main/java/org/springframework/boot/testsupport/testcontainers/DockerImageNames.java @@ -24,6 +24,7 @@ import org.testcontainers.utility.DockerImageName; * @author Stephane Nicoll * @author Eddú Meléndez * @author Moritz Halbritter + * @author Chris Bono * @since 2.3.6 */ public final class DockerImageNames { @@ -50,6 +51,8 @@ public final class DockerImageNames { private static final String ORACLE_XE_VERSION = "18.4.0-slim"; + private static final String PULSAR_VERSION = "3.1.0"; + private static final String POSTGRESQL_VERSION = "14.0"; private static final String RABBIT_VERSION = "3.11-alpine"; @@ -153,6 +156,14 @@ public final class DockerImageNames { return DockerImageName.parse("gvenzl/oracle-xe").withTag(ORACLE_XE_VERSION); } + /** + * Return a {@link DockerImageName} suitable for running Apache Pulsar. + * @return a docker image name for running pulsar + */ + public static DockerImageName pulsar() { + return DockerImageName.parse("apachepulsar/pulsar").withTag(PULSAR_VERSION); + } + /** * Return a {@link DockerImageName} suitable for running PostgreSQL. * @return a docker image name for running postgresql diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar-reactive/build.gradle b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar-reactive/build.gradle new file mode 100644 index 0000000000..34c98479a7 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar-reactive/build.gradle @@ -0,0 +1,15 @@ +plugins { + id "java" + id "org.springframework.boot.conventions" +} + +description = "Spring Boot Pulsar smoke test" + +dependencies { + implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-pulsar-reactive")) + testImplementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-test")) + testImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-test-support")) + testImplementation("org.awaitility:awaitility") + testImplementation("org.testcontainers:junit-jupiter") + testImplementation("org.testcontainers:pulsar") +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar-reactive/src/main/java/smoketest/pulsar/reactive/SampleMessage.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar-reactive/src/main/java/smoketest/pulsar/reactive/SampleMessage.java new file mode 100644 index 0000000000..7fde2d1b97 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar-reactive/src/main/java/smoketest/pulsar/reactive/SampleMessage.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * 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 + * + * https://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 smoketest.pulsar.reactive; + +record SampleMessage(Integer id, String content) { +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar-reactive/src/main/java/smoketest/pulsar/reactive/SampleMessageConsumer.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar-reactive/src/main/java/smoketest/pulsar/reactive/SampleMessageConsumer.java new file mode 100644 index 0000000000..02cbe74469 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar-reactive/src/main/java/smoketest/pulsar/reactive/SampleMessageConsumer.java @@ -0,0 +1,43 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * 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 + * + * https://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 smoketest.pulsar.reactive; + +import java.util.ArrayList; +import java.util.List; + +import reactor.core.publisher.Mono; + +import org.springframework.pulsar.reactive.config.annotation.ReactivePulsarListener; +import org.springframework.stereotype.Component; + +@Component +class SampleMessageConsumer { + + private List consumed = new ArrayList<>(); + + List getConsumed() { + return this.consumed; + } + + @ReactivePulsarListener(topics = SampleReactivePulsarApplication.TOPIC) + Mono consumeMessagesFromPulsarTopic(SampleMessage msg) { + System.out.println("**** CONSUME: " + msg); + this.consumed.add(msg); + return Mono.empty(); + } + +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar-reactive/src/main/java/smoketest/pulsar/reactive/SampleReactivePulsarApplication.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar-reactive/src/main/java/smoketest/pulsar/reactive/SampleReactivePulsarApplication.java new file mode 100644 index 0000000000..460bc4ba4c --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar-reactive/src/main/java/smoketest/pulsar/reactive/SampleReactivePulsarApplication.java @@ -0,0 +1,53 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * 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 + * + * https://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 smoketest.pulsar.reactive; + +import org.apache.pulsar.reactive.client.api.MessageSpec; +import reactor.core.publisher.Flux; + +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.pulsar.core.PulsarTopic; +import org.springframework.pulsar.reactive.core.ReactivePulsarTemplate; + +@SpringBootApplication +public class SampleReactivePulsarApplication { + + static final String TOPIC = "pulsar-reactive-smoke-test-topic"; + + @Bean + PulsarTopic pulsarTestTopic() { + return PulsarTopic.builder(TOPIC).numberOfPartitions(1).build(); + } + + @Bean + ApplicationRunner sendMessagesToPulsarTopic(ReactivePulsarTemplate template) { + return (args) -> Flux.range(0, 10) + .map((i) -> new SampleMessage(i, "message:" + i)) + .map(MessageSpec::of) + .as((msgs) -> template.send(TOPIC, msgs)) + .doOnNext((sendResult) -> System.out.println("*** PRODUCE: " + sendResult.getMessageId())) + .subscribe(); + } + + public static void main(String[] args) { + SpringApplication.run(SampleReactivePulsarApplication.class, args); + } + +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar-reactive/src/main/resources/application.properties b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar-reactive/src/main/resources/application.properties new file mode 100644 index 0000000000..cdb9707b22 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar-reactive/src/main/resources/application.properties @@ -0,0 +1 @@ +spring.pulsar.reactive.consumer.subscription-initial-position=earliest diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar-reactive/src/test/java/smoketest/pulsar/reactive/SampleReactivePulsarApplicationTests.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar-reactive/src/test/java/smoketest/pulsar/reactive/SampleReactivePulsarApplicationTests.java new file mode 100644 index 0000000000..eb13dc4280 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar-reactive/src/test/java/smoketest/pulsar/reactive/SampleReactivePulsarApplicationTests.java @@ -0,0 +1,60 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * 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 + * + * https://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 smoketest.pulsar.reactive; + +import java.time.Duration; +import java.util.stream.IntStream; + +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.PulsarContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testsupport.testcontainers.DockerImageNames; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@Testcontainers(disabledWithoutDocker = true) +class SampleReactivePulsarApplicationTests { + + @Container + private static final PulsarContainer PULSAR_CONTAINER = new PulsarContainer(DockerImageNames.pulsar()) + .withStartupAttempts(2) + .withStartupTimeout(Duration.ofMinutes(3)); + + @DynamicPropertySource + static void pulsarProperties(DynamicPropertyRegistry registry) { + registry.add("spring.pulsar.client.service-url", PULSAR_CONTAINER::getPulsarBrokerUrl); + registry.add("spring.pulsar.admin.service-url", PULSAR_CONTAINER::getHttpServiceUrl); + } + + @Test + void appProducesAndConsumesSampleMessages(@Autowired SampleMessageConsumer consumer) { + Integer[] expectedIds = IntStream.range(0, 10).boxed().toArray(Integer[]::new); + Awaitility.await() + .atMost(Duration.ofSeconds(20)) + .untilAsserted(() -> assertThat(consumer.getConsumed()).extracting(SampleMessage::id) + .containsExactly(expectedIds)); + } + +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/build.gradle b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/build.gradle new file mode 100644 index 0000000000..e60e0ba606 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/build.gradle @@ -0,0 +1,15 @@ +plugins { + id "java" + id "org.springframework.boot.conventions" +} + +description = "Spring Boot Pulsar smoke test" + +dependencies { + implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-pulsar")) + testImplementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-test")) + testImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-test-support")) + testImplementation("org.awaitility:awaitility") + testImplementation("org.testcontainers:junit-jupiter") + testImplementation("org.testcontainers:pulsar") +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/java/smoketest/pulsar/SampleMessage.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/java/smoketest/pulsar/SampleMessage.java new file mode 100644 index 0000000000..3887ce61f1 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/java/smoketest/pulsar/SampleMessage.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * 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 + * + * https://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 smoketest.pulsar; + +record SampleMessage(Integer id, String content) { +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/java/smoketest/pulsar/SampleMessageConsumer.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/java/smoketest/pulsar/SampleMessageConsumer.java new file mode 100644 index 0000000000..e3c80cf223 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/java/smoketest/pulsar/SampleMessageConsumer.java @@ -0,0 +1,40 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * 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 + * + * https://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 smoketest.pulsar; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.pulsar.annotation.PulsarListener; +import org.springframework.stereotype.Component; + +@Component +class SampleMessageConsumer { + + private List consumed = new ArrayList<>(); + + List getConsumed() { + return this.consumed; + } + + @PulsarListener(topics = SamplePulsarApplication.TOPIC) + void consumeMessagesFromPulsarTopic(SampleMessage msg) { + System.out.println("**** CONSUME: " + msg); + this.consumed.add(msg); + } + +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/java/smoketest/pulsar/SamplePulsarApplication.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/java/smoketest/pulsar/SamplePulsarApplication.java new file mode 100644 index 0000000000..adc801e3d7 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/java/smoketest/pulsar/SamplePulsarApplication.java @@ -0,0 +1,52 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * 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 + * + * https://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 smoketest.pulsar; + +import org.apache.pulsar.client.api.MessageId; + +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.pulsar.core.PulsarTemplate; +import org.springframework.pulsar.core.PulsarTopic; + +@SpringBootApplication +public class SamplePulsarApplication { + + static final String TOPIC = "pulsar-smoke-test-topic"; + + @Bean + PulsarTopic pulsarTestTopic() { + return PulsarTopic.builder(TOPIC).numberOfPartitions(1).build(); + } + + @Bean + ApplicationRunner sendMessagesToPulsarTopic(PulsarTemplate template) { + return (args) -> { + for (int i = 0; i < 10; i++) { + MessageId msgId = template.send(TOPIC, new SampleMessage(i, "message:" + i)); + System.out.println("*** PRODUCE: " + msgId); + } + }; + } + + public static void main(String[] args) { + SpringApplication.run(SamplePulsarApplication.class, args); + } + +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/resources/application.properties b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/resources/application.properties new file mode 100644 index 0000000000..25502d64c7 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/main/resources/application.properties @@ -0,0 +1 @@ +spring.pulsar.consumer.subscription-initial-position=earliest diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/test/java/smoketest/pulsar/SamplePulsarApplicationTests.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/test/java/smoketest/pulsar/SamplePulsarApplicationTests.java new file mode 100644 index 0000000000..4918a58ba4 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-pulsar/src/test/java/smoketest/pulsar/SamplePulsarApplicationTests.java @@ -0,0 +1,60 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * 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 + * + * https://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 smoketest.pulsar; + +import java.time.Duration; +import java.util.stream.IntStream; + +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.PulsarContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testsupport.testcontainers.DockerImageNames; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@Testcontainers(disabledWithoutDocker = true) +class SamplePulsarApplicationTests { + + @Container + private static final PulsarContainer PULSAR_CONTAINER = new PulsarContainer(DockerImageNames.pulsar()) + .withStartupAttempts(2) + .withStartupTimeout(Duration.ofMinutes(3)); + + @DynamicPropertySource + static void pulsarProperties(DynamicPropertyRegistry registry) { + registry.add("spring.pulsar.client.service-url", PULSAR_CONTAINER::getPulsarBrokerUrl); + registry.add("spring.pulsar.admin.service-url", PULSAR_CONTAINER::getHttpServiceUrl); + } + + @Test + void appProducesAndConsumesSampleMessages(@Autowired SampleMessageConsumer consumer) { + Integer[] expectedIds = IntStream.range(0, 10).boxed().toArray(Integer[]::new); + Awaitility.await() + .atMost(Duration.ofSeconds(20)) + .untilAsserted(() -> assertThat(consumer.getConsumed()).extracting(SampleMessage::id) + .containsExactly(expectedIds)); + } + +}