Allow custom object mapper for messages (#755)
This commit adds support for a user-provided Jackson ObjectMapper to be used when de/serializing JSON messages. Additionally, adds Gradle test fixtures to the spring-pulsar module and deprecates the UserRecord and UserPojo in spring-pulsar-test in favor of their equivalent in the test fixture. See #723
This commit is contained in:
@@ -21,6 +21,7 @@
|
||||
*** xref:reference/reactive-pulsar/reactive-message-consumption.adoc[]
|
||||
*** xref:reference/tombstones-reactive.adoc[]
|
||||
** xref:reference/topic-resolution.adoc[]
|
||||
** xref:reference/custom-object-mapper.adoc[]
|
||||
** xref:reference/pulsar-admin.adoc[]
|
||||
** xref:reference/pulsar-function.adoc[]
|
||||
** xref:reference/observability.adoc[]
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
[[custom-object-mapper]]
|
||||
= Custom Object Mapper
|
||||
include::../attributes/attributes.adoc[]
|
||||
|
||||
Pulsar uses an internal Jackson `ObjectMapper` when de/serializing JSON messages.
|
||||
If you instead want to provide your own object mapper instance, you can register a `SchemaResolverCustomizer` and set your mapper on the `DefaultSchemaResolver` as follows:
|
||||
|
||||
[source,java,indent=0,subs="verbatim"]
|
||||
----
|
||||
@Bean
|
||||
SchemaResolverCustomizer<DefaultSchemaResolver> schemaResolverCustomizer() {
|
||||
return (DefaultSchemaResolver schemaResolver) -> {
|
||||
var myObjectMapper = obtainMyObjectMapper();
|
||||
schemaResolver.setObjectMapper(myObjectMapper);
|
||||
};
|
||||
}
|
||||
----
|
||||
|
||||
This results in your object mapper being used to de/serialize all JSON messages that go through the schema resolution process (i.e. in cases where you do not pass a schema in directly when producing/consuming messages).
|
||||
|
||||
Under the hood, the resolver creates a special JSON schema which leverages the custom mapper and is used as the schema for all resolved JSON messages.
|
||||
|
||||
If you need to pass schema instances directly you can use the `JSONSchemaUtil` to create schemas that respect the custom mapper.
|
||||
The following example shows how to do this when sending a message with the `PulsarTemplate` variant that takes a schema parameter:
|
||||
|
||||
[source,java,indent=0,subs="verbatim"]
|
||||
----
|
||||
void sendMessage(PulsarTemplate<MyPojo> template, MyPojo toSend) {
|
||||
var myObjectMapper = obtainMyObjectMapper();
|
||||
var schema = JSONSchemaUtil.schemaForTypeWithObjectMapper(MyPojo.class, myObjectMapper);
|
||||
template.send(toSend, schema);
|
||||
}
|
||||
----
|
||||
|
||||
|
||||
[CAUTION]
|
||||
====
|
||||
Pulsar configures its default object mapper in a particular way.
|
||||
Unless you have a specific reason to not do so, it is highly recommended that you configure your mapper with these same options as follows:
|
||||
[source,java,indent=0,subs="verbatim"]
|
||||
----
|
||||
myObjectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
myObjectMapper.configure(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL, false);
|
||||
myObjectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
|
||||
----
|
||||
|
||||
====
|
||||
NOTE: A later version of the framework may instead provide a customizer that operates on the default mapper rather than requiring a separate instance.
|
||||
@@ -1,5 +1,15 @@
|
||||
= What's new?
|
||||
|
||||
[[what-s-new-in-1-2-since-1-1]]
|
||||
== What's New in 1.2 Since 1.1
|
||||
:page-section-summary-toc: 1
|
||||
|
||||
This section covers the changes made from version 1.1 to version 1.2.
|
||||
|
||||
=== Custom Object Mapper
|
||||
You can provide your own Jackson `ObjectMapper` that Pulsar will use when producing and consuming JSON messages.
|
||||
See xref:./reference/custom-object-mapper.adoc[Custom Object Mapper] for more details.
|
||||
|
||||
[[what-s-new-in-1-1-since-1-0]]
|
||||
== What's New in 1.1 Since 1.0
|
||||
:page-section-summary-toc: 1
|
||||
|
||||
@@ -33,6 +33,7 @@ dependencies {
|
||||
optional libs.json.path
|
||||
|
||||
testImplementation project(':spring-pulsar-test')
|
||||
testImplementation(testFixtures(project(":spring-pulsar")))
|
||||
testRuntimeOnly libs.logback.classic
|
||||
testImplementation 'io.projectreactor:reactor-test'
|
||||
testImplementation 'org.assertj:assertj-core'
|
||||
|
||||
@@ -47,8 +47,10 @@ import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.pulsar.core.DefaultSchemaResolver;
|
||||
import org.springframework.pulsar.core.DefaultTopicResolver;
|
||||
import org.springframework.pulsar.core.JSONSchemaUtil;
|
||||
import org.springframework.pulsar.test.model.UserRecord;
|
||||
import org.springframework.pulsar.test.model.json.UserRecordObjectMapper;
|
||||
import org.springframework.pulsar.test.support.PulsarTestContainerSupport;
|
||||
import org.springframework.pulsar.test.support.model.UserRecord;
|
||||
import org.springframework.util.function.ThrowingConsumer;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -332,6 +334,25 @@ class ReactivePulsarTemplateTests implements PulsarTestContainerSupport {
|
||||
|
||||
}
|
||||
|
||||
@Nested
|
||||
class CustomObjectMapperTests {
|
||||
|
||||
@Test
|
||||
void sendWithCustomJsonSchema() throws Exception {
|
||||
// Prepare the schema with custom object mapper
|
||||
var objectMapper = UserRecordObjectMapper.withSer();
|
||||
var schema = JSONSchemaUtil.schemaForTypeWithObjectMapper(UserRecord.class, objectMapper);
|
||||
var topic = "rptt-custom-object-mapper-topic";
|
||||
var user = new UserRecord("elFoo", 21);
|
||||
// serializer adds '-ser' to name and 10 to age
|
||||
var expectedUser = new UserRecord("elFoo-ser", 31);
|
||||
ThrowingConsumer<ReactivePulsarTemplate<UserRecord>> sendFunction = (
|
||||
template) -> template.send(topic, user, schema).subscribe();
|
||||
sendAndConsume(sendFunction, topic, schema, expectedUser, false);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static class Foo {
|
||||
|
||||
private String foo;
|
||||
|
||||
@@ -22,6 +22,7 @@ import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.apache.pulsar.client.api.DeadLetterPolicy;
|
||||
import org.apache.pulsar.client.api.PulsarClient;
|
||||
@@ -37,10 +38,13 @@ import org.assertj.core.api.InstanceOfAssertFactories;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.core.log.LogAccessor;
|
||||
import org.springframework.pulsar.core.JSONSchemaUtil;
|
||||
import org.springframework.pulsar.reactive.core.DefaultReactivePulsarConsumerFactory;
|
||||
import org.springframework.pulsar.reactive.core.DefaultReactivePulsarSenderFactory;
|
||||
import org.springframework.pulsar.reactive.core.ReactiveMessageConsumerBuilderCustomizer;
|
||||
import org.springframework.pulsar.reactive.core.ReactivePulsarTemplate;
|
||||
import org.springframework.pulsar.test.model.UserRecord;
|
||||
import org.springframework.pulsar.test.model.json.UserRecordObjectMapper;
|
||||
import org.springframework.pulsar.test.support.PulsarTestContainerSupport;
|
||||
|
||||
import reactor.core.publisher.Flux;
|
||||
@@ -306,28 +310,69 @@ class DefaultReactivePulsarMessageListenerContainerTests implements PulsarTestCo
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void oneByOneMessageHandlerWithCustomObjectMapper() throws Exception {
|
||||
var pulsarClient = PulsarClient.builder().serviceUrl(PulsarTestContainerSupport.getPulsarBrokerUrl()).build();
|
||||
ReactivePulsarMessageListenerContainer<UserRecord> container = null;
|
||||
try {
|
||||
// Prepare the schema with custom object mapper
|
||||
var objectMapper = UserRecordObjectMapper.withDeser();
|
||||
var schema = JSONSchemaUtil.schemaForTypeWithObjectMapper(UserRecord.class, objectMapper);
|
||||
|
||||
var reactivePulsarClient = AdaptedReactivePulsarClientFactory.create(pulsarClient);
|
||||
var topic = topicNameForTest("com-topic");
|
||||
var consumerFactory = createAndPrepareConsumerFactory(topic, schema, reactivePulsarClient);
|
||||
var containerProperties = new ReactivePulsarContainerProperties<UserRecord>();
|
||||
containerProperties.setSchema(schema);
|
||||
var latch = new CountDownLatch(1);
|
||||
AtomicReference<UserRecord> consumedRecordRef = new AtomicReference<>();
|
||||
containerProperties.setMessageHandler((ReactivePulsarOneByOneMessageHandler<UserRecord>) (msg) -> {
|
||||
consumedRecordRef.set(msg.getValue());
|
||||
return Mono.fromRunnable(latch::countDown);
|
||||
});
|
||||
container = new DefaultReactivePulsarMessageListenerContainer<>(consumerFactory, containerProperties);
|
||||
container.start();
|
||||
|
||||
var sentUserRecord = new UserRecord("person", 51);
|
||||
// deser adds '-deser' to name and 5 to age
|
||||
var expectedConsumedUser = new UserRecord("person-deser", 56);
|
||||
createPulsarTemplate(topic, reactivePulsarClient).send(sentUserRecord).subscribe();
|
||||
assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue();
|
||||
assertThat(consumedRecordRef).hasValue(expectedConsumedUser);
|
||||
}
|
||||
finally {
|
||||
safeStopContainer(container);
|
||||
pulsarClient.close();
|
||||
}
|
||||
}
|
||||
|
||||
private String topicNameForTest(String suffix) {
|
||||
return "drpmlct-" + suffix;
|
||||
}
|
||||
|
||||
private DefaultReactivePulsarConsumerFactory<String> createAndPrepareConsumerFactory(String topic,
|
||||
ReactivePulsarClient reactivePulsarClient) {
|
||||
ReactiveMessageConsumerBuilderCustomizer<String> defaultConfig = (builder) -> {
|
||||
return this.createAndPrepareConsumerFactory(topic, Schema.STRING, reactivePulsarClient);
|
||||
}
|
||||
|
||||
private <T> DefaultReactivePulsarConsumerFactory<T> createAndPrepareConsumerFactory(String topic, Schema<T> schema,
|
||||
ReactivePulsarClient reactivePulsarClient) {
|
||||
ReactiveMessageConsumerBuilderCustomizer<T> defaultConfig = (builder) -> {
|
||||
builder.topic(topic);
|
||||
builder.subscriptionName(topic + "-sub");
|
||||
};
|
||||
var consumerFactory = new DefaultReactivePulsarConsumerFactory<>(reactivePulsarClient, List.of(defaultConfig));
|
||||
var consumerFactory = new DefaultReactivePulsarConsumerFactory<T>(reactivePulsarClient, List.of(defaultConfig));
|
||||
// Ensure subscription is created
|
||||
consumerFactory.createConsumer(Schema.STRING).consumeNothing().block(Duration.ofSeconds(5));
|
||||
consumerFactory.createConsumer(schema).consumeNothing().block(Duration.ofSeconds(5));
|
||||
return consumerFactory;
|
||||
}
|
||||
|
||||
private ReactivePulsarTemplate<String> createPulsarTemplate(String topic,
|
||||
private <T> ReactivePulsarTemplate<T> createPulsarTemplate(String topic,
|
||||
ReactivePulsarClient reactivePulsarClient) {
|
||||
var producerFactory = DefaultReactivePulsarSenderFactory.<String>builderFor(reactivePulsarClient)
|
||||
var producerFactory = DefaultReactivePulsarSenderFactory.<T>builderFor(reactivePulsarClient)
|
||||
.withDefaultTopic(topic)
|
||||
.build();
|
||||
return new ReactivePulsarTemplate<>(producerFactory);
|
||||
return new ReactivePulsarTemplate<T>(producerFactory);
|
||||
}
|
||||
|
||||
private void safeStopContainer(ReactivePulsarMessageListenerContainer<?> container) {
|
||||
|
||||
@@ -48,8 +48,8 @@ import org.springframework.pulsar.core.PulsarTemplate;
|
||||
import org.springframework.pulsar.reactive.config.annotation.ReactivePulsarListener;
|
||||
import org.springframework.pulsar.reactive.config.annotation.ReactivePulsarListenerMessageConsumerBuilderCustomizer;
|
||||
import org.springframework.pulsar.reactive.listener.ReactivePulsarListenerAutoConsumeSchemaTests.ReactivePulsarListenerAutoConsumeSchemaTestsConfig;
|
||||
import org.springframework.pulsar.test.support.model.UserPojo;
|
||||
import org.springframework.pulsar.test.support.model.UserRecord;
|
||||
import org.springframework.pulsar.test.model.UserPojo;
|
||||
import org.springframework.pulsar.test.model.UserRecord;
|
||||
import org.springframework.test.context.ContextConfiguration;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@@ -74,8 +74,8 @@ import org.springframework.pulsar.reactive.listener.ReactivePulsarListenerTests.
|
||||
import org.springframework.pulsar.reactive.listener.ReactivePulsarListenerTests.SubscriptionTypeTests.WithSpecificTypes.WithSpecificTypesConfig;
|
||||
import org.springframework.pulsar.reactive.support.MessageUtils;
|
||||
import org.springframework.pulsar.support.PulsarHeaders;
|
||||
import org.springframework.pulsar.test.support.model.UserPojo;
|
||||
import org.springframework.pulsar.test.support.model.UserRecord;
|
||||
import org.springframework.pulsar.test.model.UserPojo;
|
||||
import org.springframework.pulsar.test.model.UserRecord;
|
||||
import org.springframework.test.context.ContextConfiguration;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.util.ObjectUtils;
|
||||
|
||||
@@ -21,7 +21,10 @@ ext['pulsar.version'] = "${pulsarVersion}"
|
||||
dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter-pulsar'
|
||||
developmentOnly 'org.springframework.boot:spring-boot-docker-compose'
|
||||
testImplementation project(':spring-pulsar-test')
|
||||
// temporary until JsonSchemaUtil published
|
||||
implementation project(':spring-pulsar')
|
||||
implementation(testFixtures(project(":spring-pulsar")))
|
||||
implementation project(':spring-pulsar-test')
|
||||
testRuntimeOnly 'ch.qos.logback:logback-classic'
|
||||
testImplementation "org.springframework.boot:spring-boot-starter-test"
|
||||
testImplementation "org.springframework.boot:spring-boot-testcontainers"
|
||||
|
||||
@@ -27,8 +27,12 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.pulsar.annotation.PulsarListener;
|
||||
import org.springframework.pulsar.core.DefaultSchemaResolver;
|
||||
import org.springframework.pulsar.core.PulsarTemplate;
|
||||
import org.springframework.pulsar.core.PulsarTopic;
|
||||
import org.springframework.pulsar.core.SchemaResolver;
|
||||
import org.springframework.pulsar.test.model.UserRecord;
|
||||
import org.springframework.pulsar.test.model.json.UserRecordObjectMapper;
|
||||
|
||||
@SpringBootApplication
|
||||
public class ImperativeProduceAndConsumeApp {
|
||||
@@ -55,7 +59,7 @@ public class ImperativeProduceAndConsumeApp {
|
||||
};
|
||||
}
|
||||
|
||||
@PulsarListener(topics = TOPIC, subscriptionName = TOPIC+"-sub")
|
||||
@PulsarListener(topics = TOPIC, subscriptionName = TOPIC + "-sub")
|
||||
void consumePrimitiveMessagesFromPulsarTopic(String msg) {
|
||||
LOG.info("++++++CONSUME {}------", msg);
|
||||
}
|
||||
@@ -79,7 +83,7 @@ public class ImperativeProduceAndConsumeApp {
|
||||
};
|
||||
}
|
||||
|
||||
@PulsarListener(topics = TOPIC, subscriptionName = TOPIC+"-sub")
|
||||
@PulsarListener(topics = TOPIC, subscriptionName = TOPIC + "-sub")
|
||||
void consumeComplexMessagesFromPulsarTopic(Foo msg) {
|
||||
LOG.info("++++++CONSUME {}------", msg);
|
||||
}
|
||||
@@ -108,7 +112,7 @@ public class ImperativeProduceAndConsumeApp {
|
||||
};
|
||||
}
|
||||
|
||||
@PulsarListener(topics = TOPIC, subscriptionName = TOPIC+"-sub")
|
||||
@PulsarListener(topics = TOPIC, subscriptionName = TOPIC + "-sub")
|
||||
void consumePartitionedMessagesFromPulsarTopic(String msg) {
|
||||
LOG.info("++++++CONSUME {}------", msg);
|
||||
}
|
||||
@@ -132,7 +136,7 @@ public class ImperativeProduceAndConsumeApp {
|
||||
};
|
||||
}
|
||||
|
||||
@PulsarListener(topics = TOPIC, subscriptionName = TOPIC+"-sub", batch = true)
|
||||
@PulsarListener(topics = TOPIC, subscriptionName = TOPIC + "-sub", batch = true)
|
||||
void consumeBatchMessagesFromPulsarTopic(List<Foo> messages) {
|
||||
messages.forEach((msg) -> LOG.info("++++++CONSUME {}------", msg));
|
||||
}
|
||||
@@ -162,6 +166,38 @@ public class ImperativeProduceAndConsumeApp {
|
||||
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
static class ProduceConsumeCustomObjectMapper {
|
||||
|
||||
private static final String TOPIC = "produce-consume-custom-object-mapper";
|
||||
|
||||
@Bean
|
||||
SchemaResolver.SchemaResolverCustomizer<DefaultSchemaResolver> schemaResolverCustomizer() {
|
||||
return (DefaultSchemaResolver schemaResolver) -> {
|
||||
var objectMapper = UserRecordObjectMapper.withSerAndDeser();
|
||||
schemaResolver.setObjectMapper(objectMapper);
|
||||
};
|
||||
}
|
||||
|
||||
@Bean
|
||||
ApplicationRunner sendWithCustomObjectMapper(PulsarTemplate<UserRecord> template) {
|
||||
return (args) -> {
|
||||
for (int i = 0; i < 10; i++) {
|
||||
var user = new UserRecord("user-" + i, 30);
|
||||
template.send(TOPIC, user);
|
||||
LOG.info("++++++PRODUCE {}------", user);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@PulsarListener(topics = TOPIC)
|
||||
void consumeWithCustomObjectMapper(UserRecord user) {
|
||||
LOG.info("++++++CONSUME {}------", user);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
record Foo(String name, Integer value) {
|
||||
}
|
||||
|
||||
|
||||
@@ -16,14 +16,13 @@
|
||||
|
||||
package com.example;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
import com.example.ImperativeProduceAndConsumeApp.Bar;
|
||||
import com.example.ImperativeProduceAndConsumeApp.Foo;
|
||||
import org.awaitility.Awaitility;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -31,11 +30,13 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.system.CapturedOutput;
|
||||
import org.springframework.boot.test.system.OutputCaptureExtension;
|
||||
import org.springframework.pulsar.test.model.UserRecord;
|
||||
import org.springframework.pulsar.test.support.PulsarTestContainerSupport;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import com.example.ImperativeProduceAndConsumeApp.Bar;
|
||||
import com.example.ImperativeProduceAndConsumeApp.Foo;
|
||||
|
||||
@SpringBootTest
|
||||
@ExtendWith(OutputCaptureExtension.class)
|
||||
@@ -49,7 +50,7 @@ class ImperativeProduceAndConsumeAppTests implements PulsarTestContainerSupport
|
||||
|
||||
@Test
|
||||
void produceConsumeWithPrimitiveMessageType(CapturedOutput output) {
|
||||
verifyProduceConsume(output,10, (i) -> "ProduceConsumeWithPrimitiveMessageType:" + i);
|
||||
verifyProduceConsume(output, 10, (i) -> "ProduceConsumeWithPrimitiveMessageType:" + i);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -60,27 +61,42 @@ class ImperativeProduceAndConsumeAppTests implements PulsarTestContainerSupport
|
||||
|
||||
@Test
|
||||
void produceConsumeWithPartitions(CapturedOutput output) {
|
||||
verifyProduceConsume(output,10, (i) -> "ProduceConsumeWithPartitions:" + i);
|
||||
verifyProduceConsume(output, 10, (i) -> "ProduceConsumeWithPartitions:" + i);
|
||||
}
|
||||
|
||||
@Test
|
||||
void produceConsumeBatchListener(CapturedOutput output) {
|
||||
verifyProduceConsume(output,100,
|
||||
(i) -> new Foo("ProduceConsumeBatchListener", i));
|
||||
verifyProduceConsume(output, 100, (i) -> new Foo("ProduceConsumeBatchListener", i));
|
||||
}
|
||||
|
||||
@Test
|
||||
void produceConsumeDefaultMappings(CapturedOutput output) {
|
||||
verifyProduceConsume(output,10, (i) -> new Bar("ProduceConsumeDefaultMappings:" + i));
|
||||
verifyProduceConsume(output, 10, (i) -> new Bar("ProduceConsumeDefaultMappings:" + i));
|
||||
}
|
||||
|
||||
@Test
|
||||
void produceConsumeCustomObjectMapper(CapturedOutput output) {
|
||||
// base age is 30 then ser adds 10 then deser adds 5
|
||||
var expectedAge = 30 + 10 + 5;
|
||||
verifyProduceConsume(output, 10,
|
||||
(i) -> new UserRecord("user-%d".formatted(i), 30),
|
||||
(i) -> new UserRecord("user-%d-ser-deser".formatted(i), expectedAge));
|
||||
}
|
||||
|
||||
private void verifyProduceConsume(CapturedOutput output, int numExpectedMessages,
|
||||
Function<Integer, Object> expectedMessageFactory) {
|
||||
List < String > expectedOutput = new ArrayList<>();
|
||||
this.verifyProduceConsume(output, numExpectedMessages, expectedMessageFactory, expectedMessageFactory);
|
||||
}
|
||||
|
||||
private void verifyProduceConsume(CapturedOutput output, int numExpectedMessages,
|
||||
Function<Integer, Object> expectedProducedMessageFactory,
|
||||
Function<Integer, Object> expectedConsumedMessageFactory) {
|
||||
var expectedOutput = new ArrayList<String>();
|
||||
IntStream.range(0, numExpectedMessages).forEachOrdered((i) -> {
|
||||
var msg = expectedMessageFactory.apply(i);
|
||||
expectedOutput.add("++++++PRODUCE %s------".formatted(msg));
|
||||
expectedOutput.add("++++++CONSUME %s------".formatted(msg));
|
||||
var expectedProducedMsg = expectedProducedMessageFactory.apply(i);
|
||||
var expectedConsumedMsg = expectedConsumedMessageFactory.apply(i);
|
||||
expectedOutput.add("++++++PRODUCE %s------".formatted(expectedProducedMsg));
|
||||
expectedOutput.add("++++++CONSUME %s------".formatted(expectedConsumedMsg));
|
||||
});
|
||||
Awaitility.waitAtMost(Duration.ofSeconds(15))
|
||||
.untilAsserted(() -> assertThat(output).contains(expectedOutput));
|
||||
|
||||
@@ -23,7 +23,10 @@ ext['pulsar-reactive.version'] = "${pulsarReactiveVersion}"
|
||||
dependencies {
|
||||
implementation "org.springframework.boot:spring-boot-starter-pulsar-reactive"
|
||||
developmentOnly 'org.springframework.boot:spring-boot-docker-compose'
|
||||
testImplementation project(':spring-pulsar-test')
|
||||
// temporary until JsonSchemaUtil published
|
||||
implementation project(':spring-pulsar')
|
||||
implementation(testFixtures(project(":spring-pulsar")))
|
||||
implementation project(':spring-pulsar-test')
|
||||
testRuntimeOnly 'ch.qos.logback:logback-classic'
|
||||
testImplementation "org.springframework.boot:spring-boot-starter-test"
|
||||
testImplementation "org.springframework.boot:spring-boot-testcontainers"
|
||||
|
||||
@@ -31,9 +31,13 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.pulsar.annotation.PulsarListener;
|
||||
import org.springframework.pulsar.core.DefaultSchemaResolver;
|
||||
import org.springframework.pulsar.core.SchemaResolver;
|
||||
import org.springframework.pulsar.reactive.config.annotation.ReactivePulsarListener;
|
||||
import org.springframework.pulsar.reactive.config.annotation.ReactivePulsarListenerMessageConsumerBuilderCustomizer;
|
||||
import org.springframework.pulsar.reactive.core.ReactivePulsarTemplate;
|
||||
import org.springframework.pulsar.test.model.UserRecord;
|
||||
import org.springframework.pulsar.test.model.json.UserRecordObjectMapper;
|
||||
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
@@ -125,6 +129,36 @@ public class ReactiveSpringPulsarBootApp {
|
||||
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
static class ProduceConsumeCustomObjectMapper {
|
||||
|
||||
private static final String TOPIC = "sample-reactive-custom-object-mapper";
|
||||
|
||||
@Bean
|
||||
SchemaResolver.SchemaResolverCustomizer<DefaultSchemaResolver> schemaResolverCustomizer() {
|
||||
return (DefaultSchemaResolver schemaResolver) -> {
|
||||
var objectMapper = UserRecordObjectMapper.withSerAndDeser();
|
||||
schemaResolver.setObjectMapper(objectMapper);
|
||||
};
|
||||
}
|
||||
|
||||
@Bean
|
||||
ApplicationRunner sendWithCustomObjectMapper(ReactivePulsarTemplate<UserRecord> template) {
|
||||
return (args) -> Flux.range(0, 10)
|
||||
.map((i) -> MessageSpec.of(new UserRecord("user-" + i, 30)))
|
||||
.as(messages -> template.send(TOPIC, messages))
|
||||
.doOnNext((msr) -> LOG.info("++++++PRODUCE {}------", msr.getMessageSpec().getValue()))
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
@ReactivePulsarListener(topics = TOPIC, consumerCustomizer = "subscriptionInitialPositionEarliest")
|
||||
public Mono<Void> listenSimple(UserRecord user) {
|
||||
LOG.info("++++++CONSUME {}------", user);
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
record Foo(String foo, String bar) {
|
||||
}
|
||||
|
||||
|
||||
@@ -16,13 +16,14 @@
|
||||
|
||||
package com.example;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
import com.example.ReactiveSpringPulsarBootApp.Foo;
|
||||
import org.awaitility.Awaitility;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -30,11 +31,12 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.system.CapturedOutput;
|
||||
import org.springframework.boot.test.system.OutputCaptureExtension;
|
||||
import org.springframework.pulsar.test.model.UserRecord;
|
||||
import org.springframework.pulsar.test.support.PulsarTestContainerSupport;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import com.example.ReactiveSpringPulsarBootApp.Foo;
|
||||
|
||||
@SpringBootTest
|
||||
@ExtendWith(OutputCaptureExtension.class)
|
||||
@@ -61,13 +63,30 @@ class ReactiveSpringPulsarBootAppTests implements PulsarTestContainerSupport {
|
||||
verifyProduceConsume(output,10, (i) -> "ReactiveTemplateWithImperativeListener:" + i);
|
||||
}
|
||||
|
||||
@Test
|
||||
void produceConsumeCustomObjectMapper(CapturedOutput output) {
|
||||
// base age is 30 then ser adds 10 then deser adds 5
|
||||
var expectedAge = 30 + 10 + 5;
|
||||
verifyProduceConsume(output, 10,
|
||||
(i) -> new UserRecord("user-%d".formatted(i), 30),
|
||||
(i) -> new UserRecord("user-%d-ser-deser".formatted(i), expectedAge));
|
||||
|
||||
}
|
||||
|
||||
private void verifyProduceConsume(CapturedOutput output, int numExpectedMessages,
|
||||
Function<Integer, Object> expectedMessageFactory) {
|
||||
List < String > expectedOutput = new ArrayList<>();
|
||||
this.verifyProduceConsume(output, numExpectedMessages, expectedMessageFactory, expectedMessageFactory);
|
||||
}
|
||||
|
||||
private void verifyProduceConsume(CapturedOutput output, int numExpectedMessages,
|
||||
Function<Integer, Object> expectedProducedMessageFactory,
|
||||
Function<Integer, Object> expectedConsumedMessageFactory) {
|
||||
List<String> expectedOutput = new ArrayList<>();
|
||||
IntStream.range(0, numExpectedMessages).forEachOrdered((i) -> {
|
||||
var msg = expectedMessageFactory.apply(i);
|
||||
expectedOutput.add("++++++PRODUCE %s------".formatted(msg));
|
||||
expectedOutput.add("++++++CONSUME %s------".formatted(msg));
|
||||
var expectedProducedMsg = expectedProducedMessageFactory.apply(i);
|
||||
var expectedConsumedMsg = expectedConsumedMessageFactory.apply(i);
|
||||
expectedOutput.add("++++++PRODUCE %s------".formatted(expectedProducedMsg));
|
||||
expectedOutput.add("++++++CONSUME %s------".formatted(expectedConsumedMsg));
|
||||
});
|
||||
Awaitility.waitAtMost(Duration.ofSeconds(15))
|
||||
.untilAsserted(() -> assertThat(output).contains(expectedOutput));
|
||||
|
||||
@@ -23,7 +23,11 @@ import java.util.Objects;
|
||||
* <p>
|
||||
* <b>WARN</b> Do not convert this to a Record as this is used for Avro tests and Avro
|
||||
* does not work well w/ records yet.
|
||||
*
|
||||
* @deprecated this class is replaced with Gradle test fixtures and is only meant to be
|
||||
* used internally.
|
||||
*/
|
||||
@Deprecated(since = "1.2.0", forRemoval = true)
|
||||
public class UserPojo {
|
||||
|
||||
private String name;
|
||||
|
||||
@@ -21,6 +21,9 @@ package org.springframework.pulsar.test.support.model;
|
||||
*
|
||||
* @param name the user's name
|
||||
* @param age the user's age
|
||||
* @deprecated this class is replaced with Gradle test fixtures and is only meant to be
|
||||
* used internally.
|
||||
*/
|
||||
@Deprecated(since = "1.2.0", forRemoval = true)
|
||||
public record UserRecord(String name, int age) {
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
plugins {
|
||||
id 'org.springframework.pulsar.spring-module'
|
||||
id 'java-test-fixtures'
|
||||
}
|
||||
|
||||
description = 'Spring Pulsar Core'
|
||||
@@ -51,4 +52,6 @@ dependencies {
|
||||
testImplementation "org.testcontainers:mysql"
|
||||
testImplementation 'mysql:mysql-connector-java:8.0.33'
|
||||
|
||||
// Used by UserRecordDe/serializer in test fixtures
|
||||
testFixturesApi 'com.fasterxml.jackson.core:jackson-databind'
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ import org.springframework.lang.Nullable;
|
||||
import org.springframework.pulsar.annotation.PulsarMessage;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Default schema resolver capable of handling basic message types.
|
||||
*
|
||||
@@ -96,6 +98,12 @@ public class DefaultSchemaResolver implements SchemaResolver {
|
||||
|
||||
private boolean usePulsarMessageAnnotations = true;
|
||||
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
public void setObjectMapper(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether to inspect message classes for the
|
||||
* {@link PulsarMessage @PulsarMessage} annotation during schema resolution.
|
||||
@@ -168,7 +176,7 @@ public class DefaultSchemaResolver implements SchemaResolver {
|
||||
if (schema == null && returnDefault) {
|
||||
if (messageClass != null) {
|
||||
try {
|
||||
return Schema.JSON(messageClass);
|
||||
return jsonSchemaForMessageType(messageClass);
|
||||
}
|
||||
catch (Exception e) {
|
||||
this.logger.debug(e, "Failed to create JSON schema for " + messageClass.getName());
|
||||
@@ -224,7 +232,7 @@ public class DefaultSchemaResolver implements SchemaResolver {
|
||||
case LOCAL_DATE -> Schema.LOCAL_DATE;
|
||||
case LOCAL_TIME -> Schema.LOCAL_TIME;
|
||||
case LOCAL_DATE_TIME -> Schema.LOCAL_DATE_TIME;
|
||||
case JSON -> JSONSchema.of(requireNonNullMessageType(schemaType, messageType));
|
||||
case JSON -> jsonSchemaForMessageType(requireNonNullMessageType(schemaType, messageType));
|
||||
case AVRO -> AvroSchema.of(requireNonNullMessageType(schemaType, messageType));
|
||||
case PROTOBUF -> {
|
||||
// WARN! Leave GeneratedMessageV3 fully-qualified as the dependency is
|
||||
@@ -255,6 +263,13 @@ public class DefaultSchemaResolver implements SchemaResolver {
|
||||
}
|
||||
}
|
||||
|
||||
private <T> JSONSchema<T> jsonSchemaForMessageType(Class<T> messageType) {
|
||||
if (this.objectMapper != null) {
|
||||
return JSONSchemaUtil.schemaForTypeWithObjectMapper(messageType, this.objectMapper);
|
||||
}
|
||||
return JSONSchema.of(messageType);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private Class<?> requireNonNullMessageType(SchemaType schemaType, @Nullable ResolvableType messageType) {
|
||||
return Objects.requireNonNull(messageType, "messageType must be specified for " + schemaType.name())
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* 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.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.apache.pulsar.client.api.SchemaSerializationException;
|
||||
import org.apache.pulsar.client.api.schema.SchemaDefinitionBuilder;
|
||||
import org.apache.pulsar.client.api.schema.SchemaReader;
|
||||
import org.apache.pulsar.client.api.schema.SchemaWriter;
|
||||
import org.apache.pulsar.client.impl.schema.JSONSchema;
|
||||
import org.apache.pulsar.client.impl.schema.SchemaDefinitionBuilderImpl;
|
||||
|
||||
import org.springframework.core.log.LogAccessor;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectReader;
|
||||
|
||||
/**
|
||||
* Factory to create schema definition {@link SchemaDefinitionBuilder builders} that
|
||||
* provide schema definitions that use custom object mappers when de/serializing objects.
|
||||
*
|
||||
* @author Chris Bono
|
||||
* @since 1.2.0
|
||||
*/
|
||||
public interface JSONSchemaUtil {
|
||||
|
||||
/**
|
||||
* Create a new JSON schema that uses the provided object mapper to de/serialize
|
||||
* objects of the specified type.
|
||||
* @param objectType the type of objects the resulting schema represents
|
||||
* @param objectMapper the mapper used to read and write objects from JSON
|
||||
* @param <T> the type of objects the resulting schema represents
|
||||
* @return the schema instance
|
||||
*/
|
||||
static <T> JSONSchema<T> schemaForTypeWithObjectMapper(Class<T> objectType, ObjectMapper objectMapper) {
|
||||
return JSONSchemaUtil.schemaForTypeWithObjectMapper(objectType, objectMapper, (b) -> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new JSON schema that uses the provided object mapper to de/serialize
|
||||
* objects of the specified type.
|
||||
* @param objectType the type of objects the resulting schema represents
|
||||
* @param objectMapper the mapper used to read and write objects from JSON
|
||||
* @param schemaDefinitionBuilderCustomizer the schema definition builder customizer
|
||||
* @param <T> the type of objects the resulting schema represents
|
||||
* @return the schema instance
|
||||
*/
|
||||
static <T> JSONSchema<T> schemaForTypeWithObjectMapper(Class<T> objectType, ObjectMapper objectMapper,
|
||||
Consumer<SchemaDefinitionBuilder<T>> schemaDefinitionBuilderCustomizer) {
|
||||
var reader = new CustomJacksonJsonReader<>(objectMapper, objectType);
|
||||
var writer = new CustomJacksonJsonWriter<T>(objectMapper);
|
||||
var schemaDefinitionBuilder = new SchemaDefinitionBuilderImpl<T>().withPojo(objectType)
|
||||
.withSchemaReader(reader)
|
||||
.withSchemaWriter(writer);
|
||||
schemaDefinitionBuilderCustomizer.accept(schemaDefinitionBuilder);
|
||||
return JSONSchema.of(schemaDefinitionBuilder.build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Reader implementation for reading objects from JSON using a custom
|
||||
* {@code ObjectMapper}.
|
||||
*
|
||||
* @param <T> object type to read
|
||||
*/
|
||||
class CustomJacksonJsonReader<T> implements SchemaReader<T> {
|
||||
|
||||
private static final LogAccessor LOG = new LogAccessor(CustomJacksonJsonReader.class);
|
||||
|
||||
private final ObjectReader objectReader;
|
||||
|
||||
private final Class<T> objectType;
|
||||
|
||||
CustomJacksonJsonReader(ObjectMapper objectMapper, Class<T> objectType) {
|
||||
Assert.notNull(objectMapper, "objectMapper must not be null");
|
||||
Assert.notNull(objectType, "objectType must not be null");
|
||||
this.objectReader = objectMapper.readerFor(objectType);
|
||||
this.objectType = objectType;
|
||||
}
|
||||
|
||||
@Override
|
||||
public T read(byte[] bytes, int offset, int length) {
|
||||
try {
|
||||
return this.objectReader.readValue(bytes, offset, length);
|
||||
}
|
||||
catch (IOException e) {
|
||||
throw new SchemaSerializationException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public T read(InputStream inputStream) {
|
||||
try {
|
||||
return this.objectReader.readValue(inputStream, this.objectType);
|
||||
}
|
||||
catch (IOException e) {
|
||||
throw new SchemaSerializationException(e);
|
||||
}
|
||||
finally {
|
||||
try {
|
||||
inputStream.close();
|
||||
}
|
||||
catch (IOException e) {
|
||||
LOG.error(e, () -> "Failed to close input stream on read");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Writer implementation for writing objects as JSON using a custom
|
||||
* {@code ObjectMapper}.
|
||||
*
|
||||
* @param <T> object type to write
|
||||
*/
|
||||
class CustomJacksonJsonWriter<T> implements SchemaWriter<T> {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
CustomJacksonJsonWriter(ObjectMapper objectMapper) {
|
||||
Assert.notNull(objectMapper, "objectMapper must not be null");
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] write(T message) {
|
||||
try {
|
||||
return this.objectMapper.writeValueAsBytes(message);
|
||||
}
|
||||
catch (JsonProcessingException e) {
|
||||
throw new SchemaSerializationException(e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -49,7 +49,7 @@ import org.springframework.lang.Nullable;
|
||||
import org.springframework.pulsar.cache.provider.CacheProvider;
|
||||
import org.springframework.pulsar.core.CachingPulsarProducerFactory.ProducerCacheKey;
|
||||
import org.springframework.pulsar.core.CachingPulsarProducerFactory.ProducerWithCloseCallback;
|
||||
import org.springframework.pulsar.test.support.model.UserPojo;
|
||||
import org.springframework.pulsar.test.model.UserPojo;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.util.ObjectUtils;
|
||||
|
||||
|
||||
@@ -52,12 +52,16 @@ import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.EnumSource;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import org.springframework.core.ResolvableType;
|
||||
import org.springframework.pulsar.annotation.PulsarMessage;
|
||||
import org.springframework.pulsar.listener.Proto;
|
||||
import org.springframework.pulsar.listener.Proto.Person;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link DefaultSchemaResolver}.
|
||||
*
|
||||
@@ -68,6 +72,10 @@ class DefaultSchemaResolverTests {
|
||||
|
||||
private DefaultSchemaResolver resolver = new DefaultSchemaResolver();
|
||||
|
||||
private static String sanitizedClassName(Class<?> clazz) {
|
||||
return clazz.getName().replace("$", ".");
|
||||
}
|
||||
|
||||
@Nested
|
||||
class CustomSchemaMappingsAPI {
|
||||
|
||||
@@ -295,10 +303,6 @@ class DefaultSchemaResolverTests {
|
||||
.withMessage("Unsupported schema type: " + unsupportedType.name());
|
||||
}
|
||||
|
||||
private String sanitizedClassName(Class<?> clazz) {
|
||||
return clazz.getName().replace("$", ".");
|
||||
}
|
||||
|
||||
@Nested
|
||||
class SchemaTypeNone {
|
||||
|
||||
@@ -372,6 +376,38 @@ class DefaultSchemaResolverTests {
|
||||
|
||||
}
|
||||
|
||||
@Nested
|
||||
class SchemaTypeJson {
|
||||
|
||||
@Test
|
||||
void whenResolverHasObjectMapperThenReturnsCustomJsonSchema() {
|
||||
var objectMapper = new ObjectMapper();
|
||||
resolver.setObjectMapper(objectMapper);
|
||||
var schema = mock(JSONSchema.class);
|
||||
try (MockedStatic<JSONSchemaUtil> util = Mockito.mockStatic(JSONSchemaUtil.class)) {
|
||||
util.when(() -> JSONSchemaUtil.schemaForTypeWithObjectMapper(Foo.class, objectMapper))
|
||||
.thenReturn(schema);
|
||||
var resolved = resolver.resolveSchema(SchemaType.JSON, ResolvableType.forType(Foo.class));
|
||||
assertThat(resolved.value()).hasValueSatisfying((s) -> assertThat(s).isSameAs(schema));
|
||||
util.verify(() -> JSONSchemaUtil.schemaForTypeWithObjectMapper(Foo.class, objectMapper));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void whenResolverDoesNotHaveObjectMapperThenReturnsDefaultJsonSchema() {
|
||||
var objectMapper = new ObjectMapper();
|
||||
var schema = mock(JSONSchema.class);
|
||||
try (MockedStatic<JSONSchemaUtil> util = Mockito.mockStatic(JSONSchemaUtil.class)) {
|
||||
util.when(() -> JSONSchemaUtil.schemaForTypeWithObjectMapper(Foo.class, objectMapper))
|
||||
.thenReturn(schema);
|
||||
var resolved = resolver.resolveSchema(SchemaType.JSON, ResolvableType.forType(Foo.class));
|
||||
assertThat(resolved.value()).hasValueSatisfying((s) -> assertThat(s).isNotSameAs(schema));
|
||||
util.verifyNoInteractions();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Nested
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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 org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Tests for {@link JSONSchemaUtil}.
|
||||
*/
|
||||
class JsonSchemaUtilTests {
|
||||
|
||||
@Test
|
||||
void foo() {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -59,8 +59,9 @@ import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.pulsar.annotation.EnablePulsar;
|
||||
import org.springframework.pulsar.test.model.UserRecord;
|
||||
import org.springframework.pulsar.test.model.json.UserRecordObjectMapper;
|
||||
import org.springframework.pulsar.test.support.PulsarTestContainerSupport;
|
||||
import org.springframework.pulsar.test.support.model.UserRecord;
|
||||
import org.springframework.util.function.ThrowingConsumer;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -448,6 +449,24 @@ class PulsarTemplateTests implements PulsarTestContainerSupport {
|
||||
|
||||
}
|
||||
|
||||
@Nested
|
||||
class CustomObjectMapperTests {
|
||||
|
||||
@Test
|
||||
void sendWithCustomJsonSchema() throws Exception {
|
||||
// Prepare the schema with custom object mapper
|
||||
var objectMapper = UserRecordObjectMapper.withSer();
|
||||
var schema = JSONSchemaUtil.schemaForTypeWithObjectMapper(UserRecord.class, objectMapper);
|
||||
var topic = "ptt-custom-object-mapper-topic";
|
||||
var user = new UserRecord("elFoo", 21);
|
||||
// serializer adds '-ser' to name and 10 to age
|
||||
var expectedUser = new UserRecord("elFoo-ser", 31);
|
||||
ThrowingConsumer<PulsarTemplate<UserRecord>> sendFunction = (template) -> template.send(user, schema);
|
||||
sendAndConsume(sendFunction, topic, schema, expectedUser, true);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static class Foo {
|
||||
|
||||
private String foo;
|
||||
|
||||
@@ -32,6 +32,7 @@ import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.concurrent.locks.Condition;
|
||||
import java.util.concurrent.locks.Lock;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
@@ -52,7 +53,10 @@ import org.junit.jupiter.api.Test;
|
||||
import org.springframework.pulsar.core.ConsumerTestUtils;
|
||||
import org.springframework.pulsar.core.DefaultPulsarConsumerFactory;
|
||||
import org.springframework.pulsar.core.DefaultPulsarProducerFactory;
|
||||
import org.springframework.pulsar.core.JSONSchemaUtil;
|
||||
import org.springframework.pulsar.core.PulsarTemplate;
|
||||
import org.springframework.pulsar.test.model.UserRecord;
|
||||
import org.springframework.pulsar.test.model.json.UserRecordObjectMapper;
|
||||
import org.springframework.pulsar.test.support.PulsarTestContainerSupport;
|
||||
import org.springframework.pulsar.transaction.PulsarAwareTransactionManager;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
@@ -418,4 +422,44 @@ class DefaultPulsarMessageListenerContainerTests implements PulsarTestContainerS
|
||||
.withMessage("Transactional batch listeners do not support AckMode.RECORD");
|
||||
}
|
||||
|
||||
@Test
|
||||
void basicDefaultConsumerWithCustomObjectMapper() throws Exception {
|
||||
var pulsarClient = PulsarClient.builder().serviceUrl(PulsarTestContainerSupport.getPulsarBrokerUrl()).build();
|
||||
var topic = "dpmlct-com-topic";
|
||||
var pulsarConsumerFactory = new DefaultPulsarConsumerFactory<UserRecord>(pulsarClient,
|
||||
List.of((consumerBuilder) -> {
|
||||
consumerBuilder.topic(topic);
|
||||
consumerBuilder.subscriptionName("dpmlct-com-sub");
|
||||
}));
|
||||
var latch = new CountDownLatch(1);
|
||||
AtomicReference<UserRecord> consumedRecordRef = new AtomicReference<>();
|
||||
var pulsarContainerProperties = new PulsarContainerProperties();
|
||||
pulsarContainerProperties.setMessageListener((PulsarRecordMessageListener<UserRecord>) (consumer, msg) -> {
|
||||
consumedRecordRef.set(msg.getValue());
|
||||
latch.countDown();
|
||||
});
|
||||
|
||||
// Prepare the schema with custom object mapper
|
||||
var objectMapper = UserRecordObjectMapper.withDeser();
|
||||
var schema = JSONSchemaUtil.schemaForTypeWithObjectMapper(UserRecord.class, objectMapper);
|
||||
pulsarContainerProperties.setSchema(schema);
|
||||
|
||||
// Start the container
|
||||
var container = new DefaultPulsarMessageListenerContainer<>(pulsarConsumerFactory, pulsarContainerProperties);
|
||||
container.start();
|
||||
|
||||
// Send and consume message and ensure the deser was used
|
||||
var pulsarProducerFactory = new DefaultPulsarProducerFactory<UserRecord>(pulsarClient, topic);
|
||||
var pulsarTemplate = new PulsarTemplate<>(pulsarProducerFactory);
|
||||
var sentUserRecord = new UserRecord("person", 51);
|
||||
// deser adds '-deser' to name and 5 to age
|
||||
var expectedReceivedUser = new UserRecord("person-deser", 56);
|
||||
pulsarTemplate.sendAsync(sentUserRecord);
|
||||
assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue();
|
||||
assertThat(consumedRecordRef).hasValue(expectedReceivedUser);
|
||||
|
||||
container.stop();
|
||||
pulsarClient.close();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -45,8 +45,8 @@ import org.springframework.pulsar.annotation.PulsarListener;
|
||||
import org.springframework.pulsar.core.DefaultPulsarProducerFactory;
|
||||
import org.springframework.pulsar.core.PulsarTemplate;
|
||||
import org.springframework.pulsar.listener.PulsarListenerAutoConsumeSchemaTests.PulsarListenerAutoConsumeSchemaTestsConfig;
|
||||
import org.springframework.pulsar.test.support.model.UserPojo;
|
||||
import org.springframework.pulsar.test.support.model.UserRecord;
|
||||
import org.springframework.pulsar.test.model.UserPojo;
|
||||
import org.springframework.pulsar.test.model.UserRecord;
|
||||
import org.springframework.test.context.ContextConfiguration;
|
||||
|
||||
/**
|
||||
|
||||
@@ -72,8 +72,8 @@ import org.springframework.pulsar.core.TopicResolver;
|
||||
import org.springframework.pulsar.listener.PulsarListenerTests.SubscriptionTypeTests.WithDefaultType.WithDefaultTypeConfig;
|
||||
import org.springframework.pulsar.listener.PulsarListenerTests.SubscriptionTypeTests.WithSpecificTypes.WithSpecificTypesConfig;
|
||||
import org.springframework.pulsar.support.PulsarHeaders;
|
||||
import org.springframework.pulsar.test.support.model.UserPojo;
|
||||
import org.springframework.pulsar.test.support.model.UserRecord;
|
||||
import org.springframework.pulsar.test.model.UserPojo;
|
||||
import org.springframework.pulsar.test.model.UserRecord;
|
||||
import org.springframework.test.context.ContextConfiguration;
|
||||
import org.springframework.util.backoff.FixedBackOff;
|
||||
|
||||
|
||||
@@ -45,8 +45,8 @@ import org.springframework.pulsar.annotation.PulsarReader;
|
||||
import org.springframework.pulsar.core.DefaultPulsarProducerFactory;
|
||||
import org.springframework.pulsar.core.PulsarTemplate;
|
||||
import org.springframework.pulsar.reader.PulsarReaderAutoConsumeSchemaTests.PulsarReaderAutoConsumeSchemaTestsConfig;
|
||||
import org.springframework.pulsar.test.support.model.UserPojo;
|
||||
import org.springframework.pulsar.test.support.model.UserRecord;
|
||||
import org.springframework.pulsar.test.model.UserPojo;
|
||||
import org.springframework.pulsar.test.model.UserRecord;
|
||||
import org.springframework.test.context.ContextConfiguration;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright 2022-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.test.model;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Test object (user) defined via standard Java beans get/set methods.
|
||||
* <p>
|
||||
* <b>WARN</b> Do not convert this to a Record as this is used for Avro tests and Avro
|
||||
* does not work well w/ records yet.
|
||||
*/
|
||||
public class UserPojo {
|
||||
|
||||
private String name;
|
||||
|
||||
private int age;
|
||||
|
||||
UserPojo() {
|
||||
}
|
||||
|
||||
public UserPojo(String name, int age) {
|
||||
this.name = name;
|
||||
this.age = age;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public int getAge() {
|
||||
return age;
|
||||
}
|
||||
|
||||
public void setAge(int age) {
|
||||
this.age = age;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (o == null || getClass() != o.getClass()) {
|
||||
return false;
|
||||
}
|
||||
UserPojo user = (UserPojo) o;
|
||||
return age == user.age && Objects.equals(name, user.name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(name, age);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "User{" + "name='" + name + '\'' + ", age=" + age + '}';
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright 2022-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.test.model;
|
||||
|
||||
/**
|
||||
* Test object (user) defined via a Java record.
|
||||
*
|
||||
* @param name the user's name
|
||||
* @param age the user's age
|
||||
*/
|
||||
public record UserRecord(String name, int age) {
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* 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.test.model.json;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.springframework.pulsar.test.model.UserRecord;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
|
||||
|
||||
/**
|
||||
* Custom Jackson deserializer for {@link UserRecord}.
|
||||
*
|
||||
* @author Chris Bono
|
||||
* @since 1.2.0
|
||||
*/
|
||||
public class UserRecordDeserializer extends StdDeserializer<UserRecord> {
|
||||
|
||||
public UserRecordDeserializer() {
|
||||
this(null);
|
||||
}
|
||||
|
||||
public UserRecordDeserializer(Class<UserRecord> t) {
|
||||
super(t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserRecord deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException {
|
||||
JsonNode rootNode = jp.getCodec().readTree(jp);
|
||||
var name = rootNode.get("name").asText();
|
||||
var age = rootNode.get("age").asInt();
|
||||
return new UserRecord(name + "-deser", age + 5);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* 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.test.model.json;
|
||||
|
||||
import org.springframework.pulsar.test.model.UserRecord;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule;
|
||||
|
||||
/**
|
||||
* Constructs custom {@link ObjectMapper} instances that leverage the
|
||||
* {@link UserRecordSerializer} and {@link UserRecordDeserializer}.
|
||||
*/
|
||||
public final class UserRecordObjectMapper {
|
||||
|
||||
private UserRecordObjectMapper() {
|
||||
}
|
||||
|
||||
public static ObjectMapper withSer() {
|
||||
var objectMapper = new ObjectMapper();
|
||||
var module = new SimpleModule();
|
||||
module.addSerializer(UserRecord.class, new UserRecordSerializer());
|
||||
objectMapper.registerModule(module);
|
||||
return objectMapper;
|
||||
}
|
||||
|
||||
public static ObjectMapper withDeser() {
|
||||
var objectMapper = new ObjectMapper();
|
||||
var module = new SimpleModule();
|
||||
module.addDeserializer(UserRecord.class, new UserRecordDeserializer());
|
||||
objectMapper.registerModule(module);
|
||||
return objectMapper;
|
||||
}
|
||||
|
||||
public static ObjectMapper withSerAndDeser() {
|
||||
var objectMapper = new ObjectMapper();
|
||||
var module = new SimpleModule();
|
||||
module.addSerializer(UserRecord.class, new UserRecordSerializer());
|
||||
module.addDeserializer(UserRecord.class, new UserRecordDeserializer());
|
||||
objectMapper.registerModule(module);
|
||||
return objectMapper;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* 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.test.model.json;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.springframework.pulsar.test.model.UserRecord;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
|
||||
|
||||
/**
|
||||
* Custom Jackson serializer for {@link UserRecord}.
|
||||
*
|
||||
* @author Chris Bono
|
||||
* @since 1.2.0
|
||||
*/
|
||||
public class UserRecordSerializer extends StdSerializer<UserRecord> {
|
||||
|
||||
public UserRecordSerializer() {
|
||||
this(null);
|
||||
}
|
||||
|
||||
public UserRecordSerializer(Class<UserRecord> t) {
|
||||
super(t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serialize(UserRecord value, JsonGenerator jgen, SerializerProvider provider) throws IOException {
|
||||
jgen.writeStartObject();
|
||||
jgen.writeStringField("name", value.name() + "-ser");
|
||||
jgen.writeNumberField("age", value.age() + 10);
|
||||
jgen.writeEndObject();
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user