Polish "Add @PulsarTypeMapping for default topi.."
* Introduce a PulsarTypeMappingRegistry to cache annotations * Expand on the topic/schema resolver tests * Add support for KEY_VALUE on annotation
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
==== Configuration properties
|
||||
Schema mappings can be configured with the `spring.pulsar.defaults.type-mappings` property.
|
||||
The following example uses `application.yml` to add mappings for the `User` and `Address` complex objects using `AVRO` and `JSON` schemas, respectively:
|
||||
|
||||
@@ -17,6 +18,7 @@ spring:
|
||||
|
||||
NOTE: The `message-type` is the fully-qualified name of the message class.
|
||||
|
||||
==== Schema resolver customizer
|
||||
The preferred method of adding mappings is via the property mentioned above.
|
||||
However, if more control is needed you can provide a schema resolver customizer to add the mapping(s).
|
||||
|
||||
@@ -32,3 +34,18 @@ public SchemaResolverCustomizer<DefaultSchemaResolver> schemaResolverCustomizer(
|
||||
}
|
||||
}
|
||||
----
|
||||
|
||||
==== Type mapping annotation
|
||||
Another option for specifying default schema information to use for a particular message type is to mark the message class with the `@PulsarTypeMapping` annotation.
|
||||
The schema info can be specified via the `schemaType` attribute on the annotation.
|
||||
|
||||
The following example configures the system to use JSON as the default schema when producing or consuming messages of type `Foo`:
|
||||
|
||||
[source,java,indent=0,subs="verbatim"]
|
||||
----
|
||||
@PulsarTypeMapping(schemaType = SchemaType.JSON)
|
||||
record Foo(String value) {
|
||||
}
|
||||
----
|
||||
|
||||
NOTE: The annotations are looked up on-demand and their result is cached. However, there is still a small performance hit on the first lookup. If you want to disable this feature you can invoke the `usePulsarTypeMappingAnnotations(false)` method on the `DefaultSchemaResolver`.
|
||||
|
||||
@@ -35,15 +35,21 @@ NOTE: The `message-type` is the fully-qualified name of the message class.
|
||||
WARNING: If the message (or the first message of a `Publisher` input) is `null`, the framework won't be able to determine the topic from it. Another method shall be used to specify the topic if your application is likely to send `null` messages.
|
||||
|
||||
=== Specified via annotation
|
||||
When no topic passed into API and no mappings configured, the system looks for `PulsarTopic` annotation. The following example configures topic for `Baz` class using annotation:
|
||||
|
||||
When no topic is passed into the API and there are no custom topic mappings configured, the system looks for a `@PulsarTypeMapping` annotation on the class of the message being produced or consumed.
|
||||
The default topic can be specified via the `topic` attribute on the annotation.
|
||||
|
||||
The following example configures the default topic to use when producing or consuming messages of type `Foo`:
|
||||
|
||||
[source,java,indent=0,subs="verbatim"]
|
||||
----
|
||||
@PulsarTopic("baz-topic")
|
||||
record Baz(String value) {
|
||||
@PulsarTypeMapping(topic = "foo-topic")
|
||||
record Foo(String value) {
|
||||
}
|
||||
----
|
||||
|
||||
NOTE: The annotations are looked up on-demand and their result is cached. However, there is still a small performance hit on the first lookup. If you want to disable this feature you can invoke the `usePulsarTypeMappingAnnotations(false)` method on the `DefaultTopicResolver`.
|
||||
|
||||
=== Custom topic resolver
|
||||
The preferred method of adding mappings is via the property mentioned above.
|
||||
However, if more control is needed you can replace the default resolver by proving your own implementation, for example:
|
||||
|
||||
@@ -16,18 +16,23 @@
|
||||
|
||||
package org.springframework.pulsar.annotation;
|
||||
|
||||
import org.apache.pulsar.common.schema.SchemaType;
|
||||
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import org.apache.pulsar.common.schema.SchemaType;
|
||||
|
||||
/**
|
||||
* Specifies default topic and schema for class.
|
||||
* Specifies default topic and schema info for a message class.
|
||||
* <p>
|
||||
* When a message class is marked with this annotation, the topic/schema resolution
|
||||
* process will use the specified information to determine a topic/schema to use for the
|
||||
* message in process.
|
||||
*
|
||||
* @author Aleksei Arsenev
|
||||
* @author Chris Bono
|
||||
*/
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@@ -35,21 +40,41 @@ import java.lang.annotation.Target;
|
||||
public @interface PulsarTypeMapping {
|
||||
|
||||
/**
|
||||
* Default topic for class.
|
||||
* @return topic
|
||||
* Default topic for the annotated message class.
|
||||
* @return default topic for the annotated message class or empty string to indicate
|
||||
* no default topic is specified
|
||||
*/
|
||||
String topic() default "";
|
||||
|
||||
/**
|
||||
* Default schema type for class.
|
||||
* @return schema type
|
||||
* Default schema type to use for the annotated message class.
|
||||
* <p>
|
||||
* Note that when this is set to {@code KEY_VALUE} you must specify the actual key and
|
||||
* value information via the {@link #messageKeyType()} and
|
||||
* {@link #messageValueSchemaType()} attributes, respectively.
|
||||
* @return schema type to use for the annotated message class or {@code NONE} to
|
||||
* indicate no default schema is specified
|
||||
*/
|
||||
SchemaType schemaType() default SchemaType.NONE;
|
||||
|
||||
/**
|
||||
* Message key type (must be specified when schema type is {@code KEY_VALUE})
|
||||
* @return message key type
|
||||
* The message key type when schema type is set to {@code KEY_VALUE}.
|
||||
* <p>
|
||||
* When the {@link #schemaType()} is not set to {@code KEY_VALUE} this attribute is
|
||||
* ignored.
|
||||
* @return message key type when using {@code KEY_VALUE} schema type
|
||||
*/
|
||||
Class<?> messageKeyType() default Void.class;
|
||||
|
||||
/**
|
||||
* The default schema type to use for the value schema when {@link #schemaType()} is
|
||||
* set to {@code KEY_VALUE}.
|
||||
* <p>
|
||||
* When the {@link #schemaType()} is not set to {@code KEY_VALUE} this attribute is
|
||||
* ignored and the default schema type must be specified via the {@code schemaType}
|
||||
* attribute.
|
||||
* @return message value schema type when using {@code KEY_VALUE} schema type
|
||||
*/
|
||||
SchemaType messageValueSchemaType() default SchemaType.NONE;
|
||||
|
||||
}
|
||||
|
||||
@@ -39,10 +39,10 @@ import org.apache.pulsar.common.schema.KeyValueEncodingType;
|
||||
import org.apache.pulsar.common.schema.SchemaType;
|
||||
|
||||
import org.springframework.core.ResolvableType;
|
||||
import org.springframework.core.annotation.AnnotationUtils;
|
||||
import org.springframework.core.log.LogAccessor;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.pulsar.annotation.PulsarTypeMapping;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
/**
|
||||
* Default schema resolver capable of handling basic message types.
|
||||
@@ -54,6 +54,7 @@ import org.springframework.pulsar.annotation.PulsarTypeMapping;
|
||||
* @author Soby Chacko
|
||||
* @author Alexander Preuß
|
||||
* @author Chris Bono
|
||||
* @author Aleksei Arsenev
|
||||
*/
|
||||
public class DefaultSchemaResolver implements SchemaResolver {
|
||||
|
||||
@@ -91,6 +92,20 @@ public class DefaultSchemaResolver implements SchemaResolver {
|
||||
|
||||
private final Map<Class<?>, Schema<?>> customSchemaMappings = new LinkedHashMap<>();
|
||||
|
||||
private final PulsarTypeMappingRegistry pulsarTypeMappingRegistry = new PulsarTypeMappingRegistry();
|
||||
|
||||
private boolean usePulsarTypeMappingAnnotations = true;
|
||||
|
||||
/**
|
||||
* Sets whether to inspect message classes for the
|
||||
* {@link PulsarTypeMapping @PulsarTypeMapping} annotation during schema resolution.
|
||||
* @param usePulsarTypeMappingAnnotations whether to inspect messages for the
|
||||
* annotation
|
||||
*/
|
||||
public void usePulsarTypeMappingAnnotations(boolean usePulsarTypeMappingAnnotations) {
|
||||
this.usePulsarTypeMappingAnnotations = usePulsarTypeMappingAnnotations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a custom mapping from message type to schema.
|
||||
* @param messageType the message type
|
||||
@@ -139,15 +154,18 @@ public class DefaultSchemaResolver implements SchemaResolver {
|
||||
|
||||
@Nullable
|
||||
protected Schema<?> getCustomSchemaOrMaybeDefault(@Nullable Class<?> messageClass, boolean returnDefault) {
|
||||
// Check for custom schema mapping
|
||||
Schema<?> schema = this.customSchemaMappings.get(messageClass);
|
||||
if (schema == null && messageClass != null) {
|
||||
PulsarTypeMapping annotation = AnnotationUtils.findAnnotation(messageClass, PulsarTypeMapping.class);
|
||||
if (annotation != null && annotation.schemaType() != SchemaType.NONE) {
|
||||
var resolvedSchema = resolveSchema(annotation.schemaType(), messageClass, annotation.messageKeyType());
|
||||
resolvedSchema.ifResolved(objectSchema -> addCustomSchemaMapping(messageClass, objectSchema));
|
||||
schema = resolvedSchema.get().orElse(null);
|
||||
|
||||
// If no custom schema mapping found, look for @PulsarTypeMapping (if enabled)
|
||||
if (this.usePulsarTypeMappingAnnotations && schema == null && messageClass != null) {
|
||||
schema = getAnnotatedSchemaType(messageClass);
|
||||
if (schema != null) {
|
||||
this.addCustomSchemaMapping(messageClass, schema);
|
||||
}
|
||||
}
|
||||
|
||||
// If still no schema, possibly return a default
|
||||
if (schema == null && returnDefault) {
|
||||
if (messageClass != null) {
|
||||
try {
|
||||
@@ -162,6 +180,30 @@ public class DefaultSchemaResolver implements SchemaResolver {
|
||||
return schema;
|
||||
}
|
||||
|
||||
// VisibleForTesting
|
||||
Schema<?> getAnnotatedSchemaType(Class<?> messageClass) {
|
||||
PulsarTypeMapping annotation = this.pulsarTypeMappingRegistry.getTypeMappingFor(messageClass).orElse(null);
|
||||
if (annotation == null || annotation.schemaType() == SchemaType.NONE) {
|
||||
return null;
|
||||
}
|
||||
var schemaType = annotation.schemaType();
|
||||
if (schemaType != SchemaType.KEY_VALUE) {
|
||||
return resolveSchema(annotation.schemaType(), messageClass, null).value().orElse(null);
|
||||
}
|
||||
// handle complicated key value
|
||||
var messageKeyClass = annotation.messageKeyType();
|
||||
Assert.state(messageKeyClass != Void.class,
|
||||
"messageKeyClass can not be Void.class when using KEY_VALUE schema type");
|
||||
|
||||
var messageValueSchemaType = annotation.messageValueSchemaType();
|
||||
Assert.state(messageValueSchemaType != SchemaType.NONE && messageValueSchemaType != SchemaType.KEY_VALUE,
|
||||
() -> "messageValueSchemaType can not be NONE or KEY_VALUE when using KEY_VALUE schema type");
|
||||
|
||||
Schema<?> keySchema = this.resolveSchema(messageKeyClass).orElseThrow();
|
||||
Schema<?> valueSchema = this.resolveSchema(messageValueSchemaType, messageClass, null).orElseThrow();
|
||||
return Schema.KeyValue(keySchema, valueSchema, KeyValueEncodingType.INLINE);
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public <T> Resolved<Schema<T>> resolveSchema(SchemaType schemaType, @Nullable ResolvableType messageType) {
|
||||
|
||||
@@ -17,11 +17,10 @@
|
||||
package org.springframework.pulsar.core;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.springframework.core.annotation.AnnotationUtils;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.pulsar.annotation.PulsarTypeMapping;
|
||||
import org.springframework.util.StringUtils;
|
||||
@@ -34,10 +33,25 @@ import org.springframework.util.StringUtils;
|
||||
* {@link #addCustomTopicMapping(Class, String)}.
|
||||
*
|
||||
* @author Chris Bono
|
||||
* @author Aleksei Arsenev
|
||||
*/
|
||||
public class DefaultTopicResolver implements TopicResolver {
|
||||
|
||||
private final Map<Class<?>, String> customTopicMappings = new ConcurrentHashMap<>();
|
||||
private final Map<Class<?>, String> customTopicMappings = new LinkedHashMap<>();
|
||||
|
||||
private final PulsarTypeMappingRegistry pulsarTypeMappingRegistry = new PulsarTypeMappingRegistry();
|
||||
|
||||
private boolean usePulsarTypeMappingAnnotations = true;
|
||||
|
||||
/**
|
||||
* Sets whether to inspect message classes for the
|
||||
* {@link PulsarTypeMapping @PulsarTypeMapping} annotation during topic resolution.
|
||||
* @param usePulsarTypeMappingAnnotations whether to inspect messages for the
|
||||
* annotation
|
||||
*/
|
||||
public void usePulsarTypeMappingAnnotations(boolean usePulsarTypeMappingAnnotations) {
|
||||
this.usePulsarTypeMappingAnnotations = usePulsarTypeMappingAnnotations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a custom mapping from message type to topic.
|
||||
@@ -102,16 +116,18 @@ public class DefaultTopicResolver implements TopicResolver {
|
||||
if (messageType == null) {
|
||||
return Resolved.failed("Topic must be specified when the message is null");
|
||||
}
|
||||
// Check for custom topic mapping
|
||||
String topic = this.customTopicMappings.get(messageType);
|
||||
|
||||
String topic = this.getCustomTopicMappings().get(messageType);
|
||||
if (topic == null) {
|
||||
PulsarTypeMapping annotation = AnnotationUtils.findAnnotation(messageType, PulsarTypeMapping.class);
|
||||
if (annotation != null && !annotation.topic().isBlank()) {
|
||||
this.addCustomTopicMapping(messageType, annotation.topic());
|
||||
topic = annotation.topic();
|
||||
// If no custom topic mapping found, look for @PulsarTypeMapping (if enabled)
|
||||
if (this.usePulsarTypeMappingAnnotations && topic == null) {
|
||||
topic = getAnnotatedTopicInfo(messageType);
|
||||
if (topic != null) {
|
||||
this.addCustomTopicMapping(messageType, topic);
|
||||
}
|
||||
}
|
||||
|
||||
// If still no topic, consult the default topic supplier
|
||||
if (topic == null) {
|
||||
topic = defaultTopicSupplier.get();
|
||||
}
|
||||
@@ -119,4 +135,12 @@ public class DefaultTopicResolver implements TopicResolver {
|
||||
: Resolved.of(topic);
|
||||
}
|
||||
|
||||
// VisibleForTesting
|
||||
String getAnnotatedTopicInfo(Class<?> messageType) {
|
||||
return this.pulsarTypeMappingRegistry.getTypeMappingFor(messageType)
|
||||
.map(PulsarTypeMapping::topic)
|
||||
.filter(StringUtils::hasText)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright 2023-2024 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.pulsar.core;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.springframework.core.annotation.AnnotationUtils;
|
||||
import org.springframework.core.log.LogAccessor;
|
||||
import org.springframework.pulsar.annotation.PulsarTypeMapping;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
/**
|
||||
* A registry that holds the {@link PulsarTypeMapping @PulsarTypeMapping} annotations and
|
||||
* each associated class that is marked with the annotation.
|
||||
* <p>
|
||||
* The annotations are looked up on-demand and the result is cached.
|
||||
* <p>
|
||||
* Once the cache reaches a {@link #maxNumberOfMappingsCached certain size} (default of
|
||||
* {@link #DEFAULT_MAX_CACHE_SIZE}) it is cleared and the annotations will be looked up
|
||||
* again the next time they are requested.
|
||||
*
|
||||
* @author Chris Bono
|
||||
*/
|
||||
class PulsarTypeMappingRegistry {
|
||||
|
||||
private static final int DEFAULT_MAX_CACHE_SIZE = 1000;
|
||||
|
||||
private final int maxNumberOfMappingsCached;
|
||||
|
||||
private final LogAccessor logger = new LogAccessor(this.getClass());
|
||||
|
||||
private ConcurrentHashMap<Class<?>, Optional<PulsarTypeMapping>> typeMappingsByClass = new ConcurrentHashMap<>();
|
||||
|
||||
PulsarTypeMappingRegistry() {
|
||||
this(DEFAULT_MAX_CACHE_SIZE);
|
||||
}
|
||||
|
||||
PulsarTypeMappingRegistry(int maxNumberOfMappingsCached) {
|
||||
Assert.state(maxNumberOfMappingsCached > 0, "maxNumberOfMappingsCached must be > 0");
|
||||
this.maxNumberOfMappingsCached = maxNumberOfMappingsCached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the {@link PulsarTypeMapping @PulsarTypeMapping} on the specified class or
|
||||
* empty if the class is not marked with the annotation.
|
||||
* @param targetClass the class to check for the annotation
|
||||
* @return an optional containing the annotation or empty if the class is not marked
|
||||
* with the annotation.
|
||||
*/
|
||||
Optional<PulsarTypeMapping> getTypeMappingFor(Class<?> targetClass) {
|
||||
var optionalTypeMapping = this.typeMappingsByClass.computeIfAbsent(targetClass, this::findTypeMappingOn);
|
||||
if (this.typeMappingsByClass.size() > this.maxNumberOfMappingsCached) {
|
||||
this.logger
|
||||
.info(() -> "Clearing cache - max entries exceeded (%d)".formatted(this.maxNumberOfMappingsCached));
|
||||
this.typeMappingsByClass = new ConcurrentHashMap<>();
|
||||
}
|
||||
return optionalTypeMapping;
|
||||
}
|
||||
|
||||
// VisibleForTesting
|
||||
protected Optional<PulsarTypeMapping> findTypeMappingOn(Class<?> targetClass) {
|
||||
this.logger.debug(() -> "Looking for @PulsarTypeMapping on " + targetClass);
|
||||
PulsarTypeMapping annotation = AnnotationUtils.findAnnotation(targetClass, PulsarTypeMapping.class);
|
||||
return Optional.ofNullable(annotation);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -16,10 +16,15 @@
|
||||
|
||||
package org.springframework.pulsar.core;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
|
||||
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
|
||||
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
|
||||
import static org.assertj.core.api.AssertionsForClassTypes.assertThatExceptionOfType;
|
||||
import static org.junit.jupiter.params.provider.Arguments.arguments;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.sql.Time;
|
||||
@@ -57,6 +62,7 @@ import org.springframework.pulsar.listener.Proto.Person;
|
||||
* Unit tests for {@link DefaultSchemaResolver}.
|
||||
*
|
||||
* @author Chris Bono
|
||||
* @author Aleksei Arsenev
|
||||
*/
|
||||
class DefaultSchemaResolverTests {
|
||||
|
||||
@@ -202,11 +208,11 @@ class DefaultSchemaResolverTests {
|
||||
assertThat(resolver.resolveSchema(Bar.class, true).orElseThrow()).isEqualTo(Schema.BYTES);
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void annotatedMessageType() {
|
||||
assertThat(resolver.resolveSchema(Zaz.class, false).orElseThrow()).isEqualTo(Schema.STRING);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Nested
|
||||
@@ -368,6 +374,98 @@ class DefaultSchemaResolverTests {
|
||||
|
||||
}
|
||||
|
||||
@Nested
|
||||
class SchemaByAnnotatedMessageType {
|
||||
|
||||
@Test
|
||||
void annotatedMessageType() {
|
||||
resolver = spy(resolver);
|
||||
var resolvedSchema = resolver.resolveSchema(JsonMsgType.class, false).orElseThrow();
|
||||
assertThat(resolvedSchema).isInstanceOf(JSONSchema.class)
|
||||
.extracting("schema.fullName")
|
||||
.asString()
|
||||
.endsWith(JsonMsgType.class.getSimpleName());
|
||||
|
||||
// verify added to custom mappings
|
||||
assertThat(resolver.getCustomSchemaMappings().get(JsonMsgType.class)).isSameAs(resolvedSchema);
|
||||
|
||||
// verify subsequent calls skip resolution again
|
||||
assertThat(resolver.resolveSchema(JsonMsgType.class, false).orElseThrow()).isSameAs(resolvedSchema);
|
||||
verify(resolver, times(1)).getAnnotatedSchemaType(JsonMsgType.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void annotatedMessageTypeKeyValue() {
|
||||
assertThat(resolver.resolveSchema(KeyValueMsgType.class, false).orElseThrow())
|
||||
.asInstanceOf(InstanceOfAssertFactories.type(KeyValueSchema.class))
|
||||
.satisfies((keyValueSchema -> {
|
||||
assertThat(keyValueSchema.getKeySchema()).isEqualTo(Schema.STRING);
|
||||
assertThat(keyValueSchema.getValueSchema()).isInstanceOf(JSONSchema.class)
|
||||
.extracting("schema.fullName")
|
||||
.asString()
|
||||
.endsWith(KeyValueMsgType.class.getSimpleName());
|
||||
assertThat(keyValueSchema.getKeyValueEncodingType()).isEqualTo(KeyValueEncodingType.INLINE);
|
||||
}));
|
||||
}
|
||||
|
||||
@Test
|
||||
void annotatedMessageTypeKeyValueMissingKeyInfo() {
|
||||
assertThatIllegalStateException()
|
||||
.isThrownBy(() -> resolver.resolveSchema(KeyValueMsgTypeNoKeyInfo.class, false).orElseThrow())
|
||||
.withMessage("messageKeyClass can not be Void.class when using KEY_VALUE schema type");
|
||||
}
|
||||
|
||||
@Test
|
||||
void annotatedMessageTypeKeyValueMissingValueInfo() {
|
||||
assertThatIllegalStateException()
|
||||
.isThrownBy(() -> resolver.resolveSchema(KeyValueMsgTypeNoValueInfo.class, false).orElseThrow())
|
||||
.withMessage("messageValueSchemaType can not be NONE or KEY_VALUE when using KEY_VALUE schema type");
|
||||
}
|
||||
|
||||
@Test
|
||||
void annotatedMessageTypeNoSchemaInfo() {
|
||||
assertThatIllegalArgumentException()
|
||||
.isThrownBy(() -> resolver.resolveSchema(NoSchemaInfoMsgType.class, false).orElseThrow())
|
||||
.withMessage("Schema not specified and no schema found for " + NoSchemaInfoMsgType.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void annotationMappingIgnoredWhenFeatureDisabled() {
|
||||
resolver.usePulsarTypeMappingAnnotations(false);
|
||||
assertThatIllegalArgumentException()
|
||||
.isThrownBy(() -> resolver.resolveSchema(JsonMsgType.class, false).orElseThrow())
|
||||
.withMessage("Schema not specified and no schema found for " + JsonMsgType.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void customMappingTakesPrecedenceOverAnnotationMapping() {
|
||||
resolver.addCustomSchemaMapping(JsonMsgType.class, Schema.STRING);
|
||||
assertThat(resolver.resolveSchema(JsonMsgType.class, false).orElseThrow()).isEqualTo(Schema.STRING);
|
||||
}
|
||||
|
||||
@PulsarTypeMapping(schemaType = SchemaType.JSON)
|
||||
record JsonMsgType(String value) {
|
||||
}
|
||||
|
||||
@PulsarTypeMapping(schemaType = SchemaType.KEY_VALUE, messageKeyType = String.class,
|
||||
messageValueSchemaType = SchemaType.JSON)
|
||||
record KeyValueMsgType(String key) {
|
||||
}
|
||||
|
||||
@PulsarTypeMapping(schemaType = SchemaType.KEY_VALUE, messageValueSchemaType = SchemaType.JSON)
|
||||
record KeyValueMsgTypeNoKeyInfo(String key) {
|
||||
}
|
||||
|
||||
@PulsarTypeMapping(schemaType = SchemaType.KEY_VALUE, messageKeyType = String.class)
|
||||
record KeyValueMsgTypeNoValueInfo(String key) {
|
||||
}
|
||||
|
||||
@PulsarTypeMapping(topic = "ignore-topic")
|
||||
record NoSchemaInfoMsgType(String value) {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
record Foo(String value) {
|
||||
}
|
||||
|
||||
|
||||
@@ -18,9 +18,13 @@ package org.springframework.pulsar.core;
|
||||
|
||||
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
|
||||
import static org.junit.jupiter.params.provider.Arguments.arguments;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.apache.pulsar.common.schema.SchemaType;
|
||||
import org.assertj.core.api.InstanceOfAssertFactories;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
@@ -36,6 +40,7 @@ import org.springframework.pulsar.annotation.PulsarTypeMapping;
|
||||
* Unit tests for {@link DefaultTopicResolver}.
|
||||
*
|
||||
* @author Chris Bono
|
||||
* @author Aleksei Arsenev
|
||||
*/
|
||||
class DefaultTopicResolverTests {
|
||||
|
||||
@@ -116,6 +121,7 @@ class DefaultTopicResolverTests {
|
||||
arguments("nullMessageWithUserTopicAndDefault", userTopic, null, defaultTopic, userTopic),
|
||||
arguments("annotationMessageWithUserTopic", userTopic, Baz.class, defaultTopic, userTopic),
|
||||
arguments("annotationMessageNoUserTopic", null, Baz.class, defaultTopic, bazTopic),
|
||||
arguments("annotationMessageNoTopicInfo", null, BazNoTopicInfo.class, defaultTopic, defaultTopic),
|
||||
arguments("nullMessageWithDefault", null, null, defaultTopic, null),
|
||||
arguments("noMatchWithUserTopicAndDefault", userTopic, Bar.class, defaultTopic, userTopic),
|
||||
arguments("noMatchWithUserTopic", userTopic, Bar.class, null, userTopic),
|
||||
@@ -125,6 +131,40 @@ class DefaultTopicResolverTests {
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
@Nested
|
||||
class TopicByAnnotatedMessageType {
|
||||
|
||||
@Test
|
||||
void customMappingTakesPrecedenceOverAnnotationMapping() {
|
||||
assertThat(resolver.resolveTopic(null, Baz.class, () -> defaultTopic).value().orElse(null))
|
||||
.isEqualTo(bazTopic);
|
||||
resolver.addCustomTopicMapping(Baz.class, "baz-custom-topic");
|
||||
assertThat(resolver.resolveTopic(null, Baz.class, () -> defaultTopic).value().orElse(null))
|
||||
.isEqualTo("baz-custom-topic");
|
||||
}
|
||||
|
||||
@Test
|
||||
void annotationMappingIgnoredWhenFeatureDisabled() {
|
||||
resolver.usePulsarTypeMappingAnnotations(false);
|
||||
assertThat(resolver.resolveTopic(null, Baz.class, () -> defaultTopic).value().orElse(null))
|
||||
.isEqualTo(defaultTopic);
|
||||
}
|
||||
|
||||
@Test
|
||||
void annotatedMessageTypeWithTopicInfo() {
|
||||
resolver = spy(resolver);
|
||||
assertThat(resolver.resolveTopic(null, Baz.class, () -> defaultTopic).value().orElse(null))
|
||||
.isEqualTo(bazTopic);
|
||||
// verify added to custom mappings
|
||||
assertThat(resolver.getCustomTopicMappings().get(Baz.class)).isEqualTo(bazTopic);
|
||||
// verify subsequent calls skip resolution again
|
||||
assertThat(resolver.resolveTopic(null, Baz.class, () -> defaultTopic).value().orElse(null))
|
||||
.isEqualTo(bazTopic);
|
||||
verify(resolver, times(1)).getAnnotatedTopicInfo(Baz.class);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Nested
|
||||
class TopicMappingsAPI {
|
||||
|
||||
@@ -175,4 +215,8 @@ class DefaultTopicResolverTests {
|
||||
record Baz(String value) {
|
||||
}
|
||||
|
||||
@PulsarTypeMapping(schemaType = SchemaType.STRING)
|
||||
record BazNoTopicInfo(String value) {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Copyright 2023-2024 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.pulsar.core;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import org.springframework.pulsar.annotation.PulsarTypeMapping;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link PulsarTypeMappingRegistry}.
|
||||
*
|
||||
* @author Chris Bono
|
||||
*/
|
||||
class PulsarTypeMappingRegistryTests {
|
||||
|
||||
@Test
|
||||
void typeMappingFoundAndCached() {
|
||||
PulsarTypeMappingRegistry registry = spy(new PulsarTypeMappingRegistry(2));
|
||||
var typeMapping = registry.getTypeMappingFor(Foo.class);
|
||||
assertThat(typeMapping).map(PulsarTypeMapping::topic).hasValue("foo-topic");
|
||||
// subsequent calls are cached
|
||||
assertThat(registry.getTypeMappingFor(Foo.class)).isSameAs(typeMapping);
|
||||
assertThat(registry.getTypeMappingFor(Foo.class)).isSameAs(typeMapping);
|
||||
verify(registry, times(1)).findTypeMappingOn(Foo.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void typeMappingNotFoundAndCached() {
|
||||
PulsarTypeMappingRegistry registry = spy(new PulsarTypeMappingRegistry(2));
|
||||
var typeMapping = registry.getTypeMappingFor(Bar.class);
|
||||
assertThat(typeMapping).isEmpty();
|
||||
// subsequent calls are cached
|
||||
assertThat(registry.getTypeMappingFor(Bar.class)).isSameAs(typeMapping);
|
||||
assertThat(registry.getTypeMappingFor(Bar.class)).isSameAs(typeMapping);
|
||||
verify(registry, times(1)).findTypeMappingOn(Bar.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cacheIsClearedOnceMaxNumberReached() {
|
||||
PulsarTypeMappingRegistry registry = spy(new PulsarTypeMappingRegistry(2));
|
||||
registry.getTypeMappingFor(Foo.class);
|
||||
registry.getTypeMappingFor(Bar.class);
|
||||
registry.getTypeMappingFor(Zaa.class);
|
||||
// the 3rd request will force a cache clear - subsequent calls will do lookup
|
||||
// again
|
||||
registry.getTypeMappingFor(Foo.class);
|
||||
registry.getTypeMappingFor(Bar.class);
|
||||
// now values should be cached again
|
||||
registry.getTypeMappingFor(Foo.class);
|
||||
registry.getTypeMappingFor(Bar.class);
|
||||
verify(registry, times(2)).findTypeMappingOn(Foo.class);
|
||||
verify(registry, times(2)).findTypeMappingOn(Bar.class);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(ints = { -1, 0 })
|
||||
void maxNumberOfMappingsMustBePositive(int maxNumberOfMappings) {
|
||||
assertThatIllegalStateException().isThrownBy(() -> new PulsarTypeMappingRegistry(maxNumberOfMappings))
|
||||
.withMessage("maxNumberOfMappingsCached must be > 0");
|
||||
}
|
||||
|
||||
@PulsarTypeMapping(topic = "foo-topic")
|
||||
record Foo(String value) {
|
||||
}
|
||||
|
||||
record Bar(String value) {
|
||||
}
|
||||
|
||||
record Zaa(String value) {
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user