diff --git a/README.md b/README.md index b0442e35..5e00604c 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,39 @@ -![](https://github.com/reactive-commons/reactive-commons-java/workflows/reactive-commons-ci-cd/badge.svg) -[![Reactor RabbitMQ](https://maven-badges.herokuapp.com/maven-central/org.reactivecommons/async-commons-rabbit-starter/badge.svg)](https://mvnrepository.com/artifact/org.reactivecommons/async-commons-rabbit-starter) +![CI/CD](https://github.com/reactive-commons/reactive-commons-java/workflows/reactive-commons-ci-cd/badge.svg) +[![Maven Central](https://img.shields.io/maven-central/v/org.reactivecommons/async-commons-rabbit-starter)](https://central.sonatype.com/artifact/org.reactivecommons/async-commons-rabbit-starter) + # reactive-commons-java + The purpose of reactive-commons is to provide a set of abstractions and implementations over different patterns and practices that make the foundation of a reactive microservices architecture. -Docs: [https://bancolombia.github.io/reactive-commons-java/](https://bancolombia.github.io/reactive-commons-java) +Even though the main purpose is to provide such abstractions in a mostly generic way, they would be of little use without a concrete implementation. So we provide implementations in a best-effort manner that aim to be easy to change, personalize, and extend. + +The first approach to this work was to release simple abstractions and a corresponding implementation over asynchronous message-driven communication between microservices, built on top of Project Reactor and Spring Boot. + +--- + +## Documentation + +**Full documentation is available at:** + +> ### 👉 [https://bancolombia.github.io/reactive-commons-java/](https://bancolombia.github.io/reactive-commons-java) + +--- + +## Related + +> - **Other projects:** [https://github.com/bancolombia](https://github.com/bancolombia) +> - **Sponsored by:** [Bancolombia Tech](https://medium.com/bancolombia-tech) + +--- -Other projects: https://github.com/bancolombia +## Third-Party Code Credits -Sponsor by: https://medium.com/bancolombia-tech +This project includes source code internalized from the following open-source libraries: -Even though the main purpose is to provide such abstractions in a mostly generic way such abstractions would be of little use without a concrete implementation so we provide some implementations in a best effors maner that aim to be easy to change, personalize and extend. +### reactor-rabbitmq +- **Repository:** [https://github.com/spring-attic/reactor-rabbitmq](https://github.com/spring-attic/reactor-rabbitmq) +- **License:** [Apache License 2.0](https://github.com/spring-attic/reactor-rabbitmq/blob/main/LICENSE) -The first approach to this work was to release a very simple abstractions and a corresponding implementation over asyncronous message driven communication between microservices build on top of project-reactor and spring boot. +### CloudEvents JSON Jackson +- **Repository:** [https://github.com/cloudevents/sdk-java](https://github.com/cloudevents/sdk-java) +- **License:** [Apache License 2.0](https://github.com/cloudevents/sdk-java/blob/main/LICENSE) diff --git a/async/async-commons-api/src/test/java/org/reactivecommons/async/api/HandlerRegistryTest.java b/async/async-commons-api/src/test/java/org/reactivecommons/async/api/HandlerRegistryTest.java index eb761c49..9bacee8a 100644 --- a/async/async-commons-api/src/test/java/org/reactivecommons/async/api/HandlerRegistryTest.java +++ b/async/async-commons-api/src/test/java/org/reactivecommons/async/api/HandlerRegistryTest.java @@ -296,6 +296,110 @@ void serveQueryDelegateWithLambda() { .hasSize(1); } + @Test + void handleCommandWithDomain() { + SomeDomainCommandHandler handler = new SomeDomainCommandHandler<>(); + registry.handleCommand(domain, name, handler, SomeDataClass.class); + assertThat(registry.getCommandHandlers().get(domain)) + .anySatisfy(registered -> assertThat(registered) + .extracting(RegisteredCommandHandler::path, RegisteredCommandHandler::inputClass) + .containsExactly(name, SomeDataClass.class)).hasSize(1); + } + + @Test + void handleCloudEventCommandWithDomain() { + SomeCloudCommandHandler handler = new SomeCloudCommandHandler(); + registry.handleCloudEventCommand(domain, name, handler); + assertThat(registry.getCommandHandlers().get(domain)) + .anySatisfy(registered -> assertThat(registered) + .extracting(RegisteredCommandHandler::path, RegisteredCommandHandler::inputClass) + .containsExactly(name, CloudEvent.class)).hasSize(1); + } + + @Test + void handleRawCommandWithDomain() { + SomeRawCommandEventHandler handler = new SomeRawCommandEventHandler(); + registry.handleRawCommand(domain, handler); + assertThat(registry.getCommandHandlers().get(domain)) + .anySatisfy(registered -> assertThat(registered) + .extracting(RegisteredCommandHandler::path, RegisteredCommandHandler::inputClass) + .containsExactly("", RawMessage.class)).hasSize(1); + } + + @Test + void listenDomainRawEvent() { + SomeRawEventHandler handler = new SomeRawEventHandler(); + registry.listenDomainRawEvent(domain, name, handler); + assertThat(registry.getDomainEventListeners().get(domain)) + .anySatisfy(registered -> assertThat(registered) + .extracting(RegisteredEventListener::path, RegisteredEventListener::inputClass) + .containsExactly(name, RawMessage.class)).hasSize(1); + } + + @Test + void listenNotificationEventWithDomain() { + registry.listenNotificationEvent(domain, name, evt -> Mono.empty(), SomeDataClass.class); + assertThat(registry.getEventNotificationListener().get(domain)) + .anySatisfy(listener -> assertThat(listener.path()).isEqualTo(name)).hasSize(1); + } + + @Test + void listenNotificationCloudEventWithDomain() { + registry.listenNotificationCloudEvent(domain, name, ce -> Mono.empty()); + assertThat(registry.getEventNotificationListener().get(domain)) + .anySatisfy(listener -> assertThat(listener) + .extracting(RegisteredEventListener::path, RegisteredEventListener::inputClass) + .containsExactly(name, CloudEvent.class)).hasSize(1); + } + + @Test + void listenNotificationRawEventWithDomain() { + SomeRawEventHandler handler = new SomeRawEventHandler(); + registry.listenNotificationRawEvent(domain, name, handler); + assertThat(registry.getEventNotificationListener().get(domain)) + .anySatisfy(registered -> assertThat(registered) + .extracting(RegisteredEventListener::path, RegisteredEventListener::inputClass) + .containsExactly(name, RawMessage.class)).hasSize(1); + } + + @Test + void serveCloudEventQueryDelegate() { + QueryHandlerDelegate delegate = (from, ce) -> Mono.empty(); + registry.serveCloudEventQuery(name, delegate); + assertThat(registry.getHandlers().get(DEFAULT_DOMAIN)) + .anySatisfy(registered -> assertThat(registered) + .extracting(RegisteredQueryHandler::path, RegisteredQueryHandler::queryClass) + .containsExactly(name, CloudEvent.class)).hasSize(1); + } + + @Test + void listenQueueSimple() { + registry.listenQueue("myQueue", msg -> Mono.empty()); + assertThat(registry.getQueueHandlers().get(DEFAULT_DOMAIN)) + .anySatisfy(q -> assertThat(q.queueName()).isEqualTo("myQueue")).hasSize(1); + } + + @Test + void listenQueueWithDomain() { + registry.listenQueue(domain, "myQueue", msg -> Mono.empty()); + assertThat(registry.getQueueHandlers().get(domain)) + .anySatisfy(q -> assertThat(q.queueName()).isEqualTo("myQueue")).hasSize(1); + } + + @Test + void listenQueueWithTopology() { + registry.listenQueue("myQueue", msg -> Mono.empty(), creator -> Mono.empty()); + assertThat(registry.getQueueHandlers().get(DEFAULT_DOMAIN)) + .anySatisfy(q -> assertThat(q.queueName()).isEqualTo("myQueue")).hasSize(1); + } + + @Test + void listenQueueWithDomainAndTopology() { + registry.listenQueue(domain, "myQueue", msg -> Mono.empty(), creator -> Mono.empty()); + assertThat(registry.getQueueHandlers().get(domain)) + .anySatisfy(q -> assertThat(q.queueName()).isEqualTo("myQueue")).hasSize(1); + } + private static class SomeQueryHandlerDelegate implements QueryHandlerDelegate { @Override public Mono handle(From from, SomeDataClass message) { diff --git a/async/async-commons/src/test/java/org/reactivecommons/async/commons/CommandExecutorTest.java b/async/async-commons/src/test/java/org/reactivecommons/async/commons/CommandExecutorTest.java new file mode 100644 index 00000000..e5e0c495 --- /dev/null +++ b/async/async-commons/src/test/java/org/reactivecommons/async/commons/CommandExecutorTest.java @@ -0,0 +1,47 @@ +package org.reactivecommons.async.commons; + +import org.junit.jupiter.api.Test; +import org.reactivecommons.async.api.handlers.CommandHandler; +import org.reactivecommons.async.commons.communications.Message; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class CommandExecutorTest { + + @SuppressWarnings("unchecked") + @Test + void executeCallsHandlerWithConvertedMessage() { + CommandHandler handler = mock(CommandHandler.class); + when(handler.handle(any())).thenReturn(Mono.empty()); + + CommandExecutor executor = new CommandExecutor<>(handler, msg -> "converted"); + StepVerifier.create(executor.execute(createMessage())) + .verifyComplete(); + } + + private Message createMessage() { + return new Message() { + @Override + public String getType() { return "test"; } + @Override + public byte[] getBody() { return new byte[0]; } + @Override + public Properties getProperties() { + return new Properties() { + @Override + public String getContentType() { return "application/json"; } + @Override + public long getContentLength() { return 0; } + @Override + public Map getHeaders() { return Map.of(); } + }; + } + }; + } +} diff --git a/async/async-commons/src/test/java/org/reactivecommons/async/commons/DLQDiscardNotifierTest.java b/async/async-commons/src/test/java/org/reactivecommons/async/commons/DLQDiscardNotifierTest.java new file mode 100644 index 00000000..88aa54c2 --- /dev/null +++ b/async/async-commons/src/test/java/org/reactivecommons/async/commons/DLQDiscardNotifierTest.java @@ -0,0 +1,167 @@ +package org.reactivecommons.async.commons; + +import io.cloudevents.CloudEvent; +import io.cloudevents.core.builder.CloudEventBuilder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.reactivecommons.api.domain.DomainEvent; +import org.reactivecommons.api.domain.DomainEventBus; +import org.reactivecommons.async.commons.communications.Message; +import org.reactivecommons.async.commons.converters.MessageConverter; +import org.reactivecommons.async.commons.exceptions.MessageConversionException; +import reactor.core.publisher.Mono; + +import java.net.URI; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@SuppressWarnings("unchecked") +class DLQDiscardNotifierTest { + + DomainEventBus eventBus; + MessageConverter messageConverter; + DLQDiscardNotifier notifier; + + @BeforeEach + void setUp() { + eventBus = mock(DomainEventBus.class); + messageConverter = mock(MessageConverter.class); + notifier = new DLQDiscardNotifier(eventBus, messageConverter); + when(eventBus.emit(any(DomainEvent.class))).thenReturn(Mono.empty()); + when(eventBus.emit(any(CloudEvent.class))).thenReturn(Mono.empty()); + } + + @Test + void notifyDiscardWithCloudEvent() { + var message = createMessage("body".getBytes(), "application/cloudevents+json"); + var cloudEvent = CloudEventBuilder.v1() + .withId("123") + .withType("test.type") + .withSource(URI.create("/test")) + .build(); + when(messageConverter.readCloudEvent(message)).thenReturn(cloudEvent); + + notifier.notifyDiscard(message).block(); + + verify(eventBus).emit(argThat((CloudEvent ce) -> ce.getType().equals("test.type.dlq"))); + } + + @Test + void notifyDiscardWithCommandJson() { + var message = createMessage( + "{\"name\":\"myCmd\",\"commandId\":\"cid\",\"data\":{}}".getBytes(), + "application/json"); + + // The notifier reads the message as JsonSkeleton via messageConverter.readValue + // We mock readValue to throw for CloudEvent check (not cloud event), then succeed for skeleton + when(messageConverter.readCloudEvent(message)).thenThrow(new RuntimeException("not a CE")); + + // Since message content-type is not cloud event, it reads JsonSkeleton + // We need to mock readValue for the private JsonSkeleton class + // Instead, use doAnswer to return a command-like object + doAnswer(inv -> { + Class cls = inv.getArgument(1); + var mapper = new tools.jackson.databind.json.JsonMapper(); + return mapper.readValue(message.getBody(), cls); + }).when(messageConverter).readValue(eq(message), any(Class.class)); + + notifier.notifyDiscard(message).block(); + + verify(eventBus).emit(argThat((DomainEvent ev) -> ev.getName().equals("myCmd.dlq"))); + } + + @Test + void notifyDiscardWithEventJson() { + var message = createMessage( + "{\"name\":\"myEvt\",\"eventId\":\"eid\",\"data\":{}}".getBytes(), + "application/json"); + + doAnswer(inv -> { + Class cls = inv.getArgument(1); + var mapper = new tools.jackson.databind.json.JsonMapper(); + return mapper.readValue(message.getBody(), cls); + }).when(messageConverter).readValue(eq(message), any(Class.class)); + + notifier.notifyDiscard(message).block(); + + verify(eventBus).emit(argThat((DomainEvent ev) -> ev.getName().equals("myEvt.dlq"))); + } + + @Test + void notifyDiscardWithQueryJson() { + var message = createMessage( + "{\"resource\":\"query.test\",\"queryData\":{}}".getBytes(), + "application/json"); + + doAnswer(inv -> { + Class cls = inv.getArgument(1); + var mapper = new tools.jackson.databind.json.JsonMapper(); + return mapper.readValue(message.getBody(), cls); + }).when(messageConverter).readValue(eq(message), any(Class.class)); + + notifier.notifyDiscard(message).block(); + + verify(eventBus).emit(argThat((DomainEvent ev) -> ev.getName().equals("query.test.dlq"))); + } + + @Test + void notifyDiscardWithUnreadableMessage() { + var message = createMessage("garbage".getBytes(), "application/json"); + + when(messageConverter.readValue(eq(message), any(Class.class))) + .thenThrow(new MessageConversionException("cannot read")); + + notifier.notifyDiscard(message).block(); + + verify(eventBus).emit(argThat((DomainEvent ev) -> ev.getName().equals("corruptData.dlq"))); + } + + @Test + void notifyDiscardHandlesEmitError() { + var message = createMessage("body".getBytes(), "application/json"); + when(messageConverter.readValue(eq(message), any(Class.class))) + .thenThrow(new MessageConversionException("bad")); + when(eventBus.emit(any(DomainEvent.class))).thenReturn(Mono.error(new RuntimeException("bus error"))); + + // Should not throw, returns empty + var result = notifier.notifyDiscard(message).block(); + assertThat(result).isNull(); + } + + private Message createMessage(byte[] body, String contentType) { + return new Message() { + @Override + public String getType() { + return "test"; + } + + @Override + public byte[] getBody() { + return body; + } + + @Override + public Properties getProperties() { + return new Properties() { + @Override + public String getContentType() { + return contentType; + } + + @Override + public long getContentLength() { + return body.length; + } + + @Override + public Map getHeaders() { + return Map.of(); + } + }; + } + }; + } +} diff --git a/async/async-commons/src/test/java/org/reactivecommons/async/commons/EventExecutorTest.java b/async/async-commons/src/test/java/org/reactivecommons/async/commons/EventExecutorTest.java new file mode 100644 index 00000000..b30dbe56 --- /dev/null +++ b/async/async-commons/src/test/java/org/reactivecommons/async/commons/EventExecutorTest.java @@ -0,0 +1,47 @@ +package org.reactivecommons.async.commons; + +import org.junit.jupiter.api.Test; +import org.reactivecommons.async.api.handlers.EventHandler; +import org.reactivecommons.async.commons.communications.Message; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class EventExecutorTest { + + @SuppressWarnings("unchecked") + @Test + void executeCallsHandlerWithConvertedMessage() { + EventHandler handler = mock(EventHandler.class); + when(handler.handle(any())).thenReturn(Mono.empty()); + + EventExecutor executor = new EventExecutor<>(handler, msg -> "converted"); + StepVerifier.create(executor.execute(createMessage())) + .verifyComplete(); + } + + private Message createMessage() { + return new Message() { + @Override + public String getType() { return "test"; } + @Override + public byte[] getBody() { return new byte[0]; } + @Override + public Properties getProperties() { + return new Properties() { + @Override + public String getContentType() { return "application/json"; } + @Override + public long getContentLength() { return 0; } + @Override + public Map getHeaders() { return Map.of(); } + }; + } + }; + } +} diff --git a/async/async-commons/src/test/java/org/reactivecommons/async/commons/FallbackStrategyTest.java b/async/async-commons/src/test/java/org/reactivecommons/async/commons/FallbackStrategyTest.java new file mode 100644 index 00000000..e7446d09 --- /dev/null +++ b/async/async-commons/src/test/java/org/reactivecommons/async/commons/FallbackStrategyTest.java @@ -0,0 +1,33 @@ +package org.reactivecommons.async.commons; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class FallbackStrategyTest { + + @Test + void fastRetryHasMessage() { + assertThat(FallbackStrategy.FAST_RETRY.message).contains("Fast retry"); + } + + @Test + void definitiveDiscardHasMessage() { + assertThat(FallbackStrategy.DEFINITIVE_DISCARD.message).contains("DEFINITIVE DISCARD"); + } + + @Test + void retryDlqHasMessage() { + assertThat(FallbackStrategy.RETRY_DLQ.message).contains("Retry DLQ"); + } + + @Test + void valuesReturnsAllStrategies() { + assertThat(FallbackStrategy.values()).hasSize(3); + } + + @Test + void valueOfReturnsCorrect() { + assertThat(FallbackStrategy.valueOf("FAST_RETRY")).isEqualTo(FallbackStrategy.FAST_RETRY); + } +} diff --git a/async/async-commons/src/test/java/org/reactivecommons/async/commons/HandlerResolverTest.java b/async/async-commons/src/test/java/org/reactivecommons/async/commons/HandlerResolverTest.java new file mode 100644 index 00000000..3bf3b067 --- /dev/null +++ b/async/async-commons/src/test/java/org/reactivecommons/async/commons/HandlerResolverTest.java @@ -0,0 +1,189 @@ +package org.reactivecommons.async.commons; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.reactivecommons.async.api.handlers.registered.RegisteredCommandHandler; +import org.reactivecommons.async.api.handlers.registered.RegisteredEventListener; +import org.reactivecommons.async.api.handlers.registered.RegisteredQueryHandler; +import org.reactivecommons.async.api.handlers.registered.RegisteredQueueListener; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +class HandlerResolverTest { + + Map> queryHandlers; + Map> eventListeners; + Map> eventsToBind; + Map> notificationListeners; + Map> commandHandlers; + Map queueListeners; + HandlerResolver resolver; + + @BeforeEach + void setUp() { + queryHandlers = new ConcurrentHashMap<>(); + eventListeners = new ConcurrentHashMap<>(); + eventsToBind = new ConcurrentHashMap<>(); + notificationListeners = new ConcurrentHashMap<>(); + commandHandlers = new ConcurrentHashMap<>(); + queueListeners = new ConcurrentHashMap<>(); + resolver = new HandlerResolver(queryHandlers, eventListeners, eventsToBind, + notificationListeners, commandHandlers, queueListeners); + } + + @Test + void hasNotificationListenersWhenEmpty() { + assertThat(resolver.hasNotificationListeners()).isFalse(); + } + + @Test + void hasNotificationListenersWhenPresent() { + notificationListeners.put("event.test", new RegisteredEventListener<>("event.test", mock(), Object.class)); + resolver = new HandlerResolver(queryHandlers, eventListeners, eventsToBind, + notificationListeners, commandHandlers, queueListeners); + assertThat(resolver.hasNotificationListeners()).isTrue(); + } + + @Test + void hasCommandHandlersWhenEmpty() { + assertThat(resolver.hasCommandHandlers()).isFalse(); + } + + @Test + void hasCommandHandlersWhenPresent() { + commandHandlers.put("cmd.test", new RegisteredCommandHandler<>("cmd.test", mock(), Object.class)); + resolver = new HandlerResolver(queryHandlers, eventListeners, eventsToBind, + notificationListeners, commandHandlers, queueListeners); + assertThat(resolver.hasCommandHandlers()).isTrue(); + } + + @Test + void hasQueryHandlersWhenEmpty() { + assertThat(resolver.hasQueryHandlers()).isFalse(); + } + + @Test + void hasQueryHandlersWhenPresent() { + queryHandlers.put("query.test", new RegisteredQueryHandler<>("query.test", mock(), Object.class)); + resolver = new HandlerResolver(queryHandlers, eventListeners, eventsToBind, + notificationListeners, commandHandlers, queueListeners); + assertThat(resolver.hasQueryHandlers()).isTrue(); + } + + @Test + void getQueryHandlerReturnsRegistered() { + var handler = new RegisteredQueryHandler<>("query.test", mock(), Object.class); + queryHandlers.put("query.test", handler); + assertThat(resolver.getQueryHandler("query.test")).isSameAs(handler); + } + + @Test + void getCommandHandlerReturnsRegistered() { + var handler = new RegisteredCommandHandler<>("cmd.test", mock(), Object.class); + commandHandlers.put("cmd.test", handler); + assertThat(resolver.getCommandHandler("cmd.test")).isSameAs(handler); + } + + @Test + void getEventListenerReturnsRegistered() { + var handler = new RegisteredEventListener<>("event.test", mock(), Object.class); + eventListeners.put("event.test", handler); + assertThat(resolver.getEventListener("event.test")).isSameAs(handler); + } + + @Test + void getEventListenerUsesMatcherForUnknownPath() { + var handler = new RegisteredEventListener<>("event.*", mock(), Object.class); + eventListeners.put("event.*", handler); + var result = resolver.getEventListener("event.foo"); + assertThat(result).isSameAs(handler); + } + + @Test + void getNotificationListeners() { + var handler = new RegisteredEventListener<>("notif.test", mock(), Object.class); + notificationListeners.put("notif.test", handler); + resolver = new HandlerResolver(queryHandlers, eventListeners, eventsToBind, + notificationListeners, commandHandlers, queueListeners); + Collection> listeners = resolver.getNotificationListeners(); + assertThat(listeners).hasSize(1); + } + + @Test + void getNotificationListener() { + var handler = new RegisteredEventListener<>("notif.test", mock(), Object.class); + notificationListeners.put("notif.test", handler); + resolver = new HandlerResolver(queryHandlers, eventListeners, eventsToBind, + notificationListeners, commandHandlers, queueListeners); + assertThat(resolver.getNotificationListener("notif.test")).isSameAs(handler); + } + + @Test + void getEventListenersReturnsEventsToBindValues() { + var handler = new RegisteredEventListener<>("bind.test", mock(), Object.class); + eventsToBind.put("bind.test", handler); + assertThat(resolver.getEventListeners()).containsExactly(handler); + } + + @Test + void getEventNames() { + eventListeners.put("event.a", new RegisteredEventListener<>("event.a", mock(), Object.class)); + eventListeners.put("event.b", new RegisteredEventListener<>("event.b", mock(), Object.class)); + List names = resolver.getEventNames(); + assertThat(names).containsExactlyInAnyOrder("event.a", "event.b"); + } + + @Test + void getNotificationNames() { + notificationListeners.put("n.a", new RegisteredEventListener<>("n.a", mock(), Object.class)); + resolver = new HandlerResolver(queryHandlers, eventListeners, eventsToBind, + notificationListeners, commandHandlers, queueListeners); + assertThat(resolver.getNotificationNames()).containsExactly("n.a"); + } + + @Test + void addEventListener() { + var handler = new RegisteredEventListener<>("dynamic.event", mock(), Object.class); + resolver.addEventListener(handler); + assertThat(resolver.getEventListener("dynamic.event")).isSameAs(handler); + } + + @Test + void addQueryHandler() { + var handler = new RegisteredQueryHandler<>("query.new", mock(), Object.class); + resolver.addQueryHandler(handler); + assertThat(resolver.getQueryHandler("query.new")).isSameAs(handler); + } + + @Test + void addQueryHandlerRejectsWildcard() { + var handler = new RegisteredQueryHandler<>("query.*", mock(), Object.class); + assertThatThrownBy(() -> resolver.addQueryHandler(handler)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("avoid * or #"); + } + + @Test + void addQueryHandlerRejectsHash() { + var handler = new RegisteredQueryHandler<>("query.#", mock(), Object.class); + assertThatThrownBy(() -> resolver.addQueryHandler(handler)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("avoid * or #"); + } + + @Test + void getQueueListeners() { + var ql = new RegisteredQueueListener("queue", mock(), mock()); + queueListeners.put("queue", ql); + resolver = new HandlerResolver(queryHandlers, eventListeners, eventsToBind, + notificationListeners, commandHandlers, queueListeners); + assertThat(resolver.getQueueListeners()).containsEntry("queue", ql); + } +} diff --git a/async/async-commons/src/test/java/org/reactivecommons/async/commons/QueryExecutorTest.java b/async/async-commons/src/test/java/org/reactivecommons/async/commons/QueryExecutorTest.java new file mode 100644 index 00000000..c1444ced --- /dev/null +++ b/async/async-commons/src/test/java/org/reactivecommons/async/commons/QueryExecutorTest.java @@ -0,0 +1,51 @@ +package org.reactivecommons.async.commons; + +import org.junit.jupiter.api.Test; +import org.reactivecommons.async.api.From; +import org.reactivecommons.async.api.handlers.QueryHandlerDelegate; +import org.reactivecommons.async.commons.communications.Message; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class QueryExecutorTest { + + @SuppressWarnings("unchecked") + @Test + void executeCallsHandlerWithConvertedMessage() { + QueryHandlerDelegate handler = mock(QueryHandlerDelegate.class); + when(handler.handle(any(From.class), any())).thenReturn(Mono.just("result")); + + QueryExecutor executor = new QueryExecutor<>(handler, msg -> "query"); + StepVerifier.create(executor.execute(createMessage())) + .expectNext("result") + .verifyComplete(); + } + + private Message createMessage() { + return new Message() { + @Override + public String getType() { return "test"; } + @Override + public byte[] getBody() { return new byte[0]; } + @Override + public Properties getProperties() { + return new Properties() { + @Override + public String getContentType() { return "application/json"; } + @Override + public long getContentLength() { return 0; } + @Override + public Map getHeaders() { + return Map.of("correlationID", "corr-123", "replyID", "reply-456"); + } + }; + } + }; + } +} diff --git a/async/async-commons/src/test/java/org/reactivecommons/async/commons/config/BrokerConfigTest.java b/async/async-commons/src/test/java/org/reactivecommons/async/commons/config/BrokerConfigTest.java new file mode 100644 index 00000000..8f2f6bd6 --- /dev/null +++ b/async/async-commons/src/test/java/org/reactivecommons/async/commons/config/BrokerConfigTest.java @@ -0,0 +1,36 @@ +package org.reactivecommons.async.commons.config; + +import org.junit.jupiter.api.Test; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; + +class BrokerConfigTest { + + @Test + void defaultConstructor() { + var config = new BrokerConfig(); + assertThat(config.isPersistentQueries()).isFalse(); + assertThat(config.isPersistentCommands()).isTrue(); + assertThat(config.isPersistentEvents()).isTrue(); + assertThat(config.getReplyTimeout()).isEqualTo(Duration.ofSeconds(15)); + assertThat(config.getRoutingKey()).isNotBlank(); + } + + @Test + void allArgsConstructor() { + var config = new BrokerConfig(true, false, true, Duration.ofSeconds(30)); + assertThat(config.isPersistentQueries()).isTrue(); + assertThat(config.isPersistentCommands()).isFalse(); + assertThat(config.isPersistentEvents()).isTrue(); + assertThat(config.getReplyTimeout()).isEqualTo(Duration.ofSeconds(30)); + } + + @Test + void routingKeyIsGenerated() { + var c1 = new BrokerConfig(); + var c2 = new BrokerConfig(); + assertThat(c1.getRoutingKey()).isNotEqualTo(c2.getRoutingKey()); + } +} diff --git a/async/async-commons/src/test/java/org/reactivecommons/async/commons/converters/json/CloudEventBuilderExtTest.java b/async/async-commons/src/test/java/org/reactivecommons/async/commons/converters/json/CloudEventBuilderExtTest.java new file mode 100644 index 00000000..e9a2c03a --- /dev/null +++ b/async/async-commons/src/test/java/org/reactivecommons/async/commons/converters/json/CloudEventBuilderExtTest.java @@ -0,0 +1,56 @@ +package org.reactivecommons.async.commons.converters.json; + +import io.cloudevents.CloudEvent; +import io.cloudevents.core.builder.CloudEventBuilder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class CloudEventBuilderExtTest { + + @Test + void asBytesSerializesObject() { + byte[] bytes = CloudEventBuilderExt.asBytes(new TestData("hello")); + assertThat(bytes).isNotEmpty(); + var json = new String(bytes, StandardCharsets.UTF_8); + assertThat(json).contains("hello"); + } + + @Test + void asCloudEventDataWrapsObject() { + var data = CloudEventBuilderExt.asCloudEventData(new TestData("test")); + assertThat(data.toBytes()).isNotEmpty(); + } + + @Test + void fromCloudEventDataDeserializes() { + byte[] json = "{\"value\":\"hello\"}".getBytes(StandardCharsets.UTF_8); + CloudEvent event = CloudEventBuilder.v1() + .withId("1") + .withType("test") + .withSource(URI.create("/test")) + .withData("application/json", json) + .build(); + + TestData result = CloudEventBuilderExt.fromCloudEventData(event, TestData.class); + assertThat(result.getValue()).isEqualTo("hello"); + } + + @Setter + @Getter + @NoArgsConstructor + public static class TestData { + private String value; + + public TestData(String value) { + this.value = value; + } + + } +} diff --git a/async/async-commons/src/test/java/org/reactivecommons/async/commons/converters/json/JacksonMessageConverterTest.java b/async/async-commons/src/test/java/org/reactivecommons/async/commons/converters/json/JacksonMessageConverterTest.java new file mode 100644 index 00000000..f0abf051 --- /dev/null +++ b/async/async-commons/src/test/java/org/reactivecommons/async/commons/converters/json/JacksonMessageConverterTest.java @@ -0,0 +1,132 @@ +package org.reactivecommons.async.commons.converters.json; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.reactivecommons.async.commons.communications.Message; +import org.reactivecommons.async.commons.exceptions.MessageConversionException; +import tools.jackson.databind.json.JsonMapper; + +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class JacksonMessageConverterTest { + + private static final JsonMapper MAPPER = new JsonMapper(); + + private JacksonMessageConverter converter; + + @BeforeEach + void setUp() { + converter = new JacksonMessageConverter(MAPPER) { + @Override + public Message toMessage(Object object) { + byte[] bytes = jsonMapper.writeValueAsBytes(object); + return createMessage(bytes, "application/json"); + } + }; + } + + @Test + void readAsyncQuery() { + String json = "{\"resource\":\"myQuery\",\"queryData\":{\"id\":\"1\",\"name\":\"n\",\"date\":null}}"; + var msg = createMessage(json.getBytes(StandardCharsets.UTF_8), "application/json"); + var result = converter.readAsyncQuery(msg, SampleClass.class); + assertThat(result.getResource()).isEqualTo("myQuery"); + assertThat(result.getQueryData()).isNotNull(); + assertThat(result.getQueryData().getName()).isEqualTo("n"); + } + + @Test + void readDomainEvent() { + String json = "{\"name\":\"myEvent\",\"eventId\":\"eid123\",\"data\":{\"id\":\"1\",\"name\":\"val\",\"date\":null}}"; + var msg = createMessage(json.getBytes(StandardCharsets.UTF_8), "application/json"); + var result = converter.readDomainEvent(msg, SampleClass.class); + assertThat(result.getName()).isEqualTo("myEvent"); + assertThat(result.getEventId()).isEqualTo("eid123"); + assertThat(result.getData()).isNotNull(); + assertThat(result.getData().getName()).isEqualTo("val"); + } + + @Test + void readCommand() { + String json = "{\"name\":\"myCmd\",\"commandId\":\"cid456\",\"data\":{\"id\":\"x\",\"name\":\"v\",\"date\":null}}"; + var msg = createMessage(json.getBytes(StandardCharsets.UTF_8), "application/json"); + var result = converter.readCommand(msg, SampleClass.class); + assertThat(result.getName()).isEqualTo("myCmd"); + assertThat(result.getCommandId()).isEqualTo("cid456"); + assertThat(result.getData()).isNotNull(); + } + + @Test + void readValueThrowsOnInvalidJson() { + var msg = createMessage("not-json".getBytes(StandardCharsets.UTF_8), "application/json"); + assertThatThrownBy(() -> converter.readValue(msg, SampleClass.class)) + .isInstanceOf(MessageConversionException.class) + .hasMessageContaining("Failed to convert Message content"); + } + + @Test + void readCommandStructure() { + String json = "{\"name\":\"cmd\",\"commandId\":\"id\",\"data\":{\"field\":\"x\"}}"; + var msg = createMessage(json.getBytes(StandardCharsets.UTF_8), "application/json"); + var result = converter.readCommandStructure(msg); + assertThat(result.getName()).isEqualTo("cmd"); + assertThat(result.getCommandId()).isEqualTo("id"); + assertThat(result.getData()).isNotNull(); + } + + @Test + void readDomainEventStructure() { + String json = "{\"name\":\"ev\",\"eventId\":\"eid\",\"data\":{\"field\":\"y\"}}"; + var msg = createMessage(json.getBytes(StandardCharsets.UTF_8), "application/json"); + var result = converter.readDomainEventStructure(msg); + assertThat(result.getName()).isEqualTo("ev"); + assertThat(result.getEventId()).isEqualTo("eid"); + } + + @Test + void readAsyncQueryStructure() { + String json = "{\"resource\":\"q\",\"queryData\":{\"field\":\"z\"}}"; + var msg = createMessage(json.getBytes(StandardCharsets.UTF_8), "application/json"); + var result = converter.readAsyncQueryStructure(msg); + assertThat(result.getResource()).isEqualTo("q"); + assertThat(result.getQueryData()).isNotNull(); + } + + private Message createMessage(byte[] body, String contentType) { + return new Message() { + @Override + public String getType() { + return "test"; + } + + @Override + public byte[] getBody() { + return body; + } + + @Override + public Properties getProperties() { + return new Properties() { + @Override + public String getContentType() { + return contentType; + } + + @Override + public long getContentLength() { + return body.length; + } + + @Override + public Map getHeaders() { + return Map.of(); + } + }; + } + }; + } +} diff --git a/async/async-commons/src/test/java/org/reactivecommons/async/commons/exceptions/MessageConversionExceptionTest.java b/async/async-commons/src/test/java/org/reactivecommons/async/commons/exceptions/MessageConversionExceptionTest.java new file mode 100644 index 00000000..3de00d54 --- /dev/null +++ b/async/async-commons/src/test/java/org/reactivecommons/async/commons/exceptions/MessageConversionExceptionTest.java @@ -0,0 +1,29 @@ +package org.reactivecommons.async.commons.exceptions; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class MessageConversionExceptionTest { + + @Test + void messageAndCause() { + var cause = new RuntimeException("root"); + var ex = new MessageConversionException("fail", cause); + assertThat(ex.getMessage()).isEqualTo("fail"); + assertThat(ex.getCause()).isSameAs(cause); + } + + @Test + void messageOnly() { + var ex = new MessageConversionException("fail"); + assertThat(ex.getMessage()).isEqualTo("fail"); + } + + @Test + void wrapsException() { + var cause = new Exception("root"); + var ex = new MessageConversionException(cause); + assertThat(ex.getCause()).isSameAs(cause); + } +} diff --git a/async/async-commons/src/test/java/org/reactivecommons/async/commons/exceptions/SendFailureNoAckExceptionTest.java b/async/async-commons/src/test/java/org/reactivecommons/async/commons/exceptions/SendFailureNoAckExceptionTest.java new file mode 100644 index 00000000..ad1bbfbf --- /dev/null +++ b/async/async-commons/src/test/java/org/reactivecommons/async/commons/exceptions/SendFailureNoAckExceptionTest.java @@ -0,0 +1,14 @@ +package org.reactivecommons.async.commons.exceptions; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class SendFailureNoAckExceptionTest { + + @Test + void messageIsSet() { + var ex = new SendFailureNoAckException("no ack"); + assertThat(ex.getMessage()).isEqualTo("no ack"); + } +} diff --git a/async/async-commons/src/test/java/org/reactivecommons/async/commons/ext/DefaultCustomReporterTest.java b/async/async-commons/src/test/java/org/reactivecommons/async/commons/ext/DefaultCustomReporterTest.java new file mode 100644 index 00000000..64e3f1f4 --- /dev/null +++ b/async/async-commons/src/test/java/org/reactivecommons/async/commons/ext/DefaultCustomReporterTest.java @@ -0,0 +1,93 @@ +package org.reactivecommons.async.commons.ext; + +import org.junit.jupiter.api.Test; +import org.reactivecommons.api.domain.Command; +import org.reactivecommons.api.domain.DomainEvent; +import org.reactivecommons.async.api.AsyncQuery; +import org.reactivecommons.async.commons.communications.Message; +import reactor.test.StepVerifier; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThatNoException; +class DefaultCustomReporterTest { + + DefaultCustomReporter reporter = new DefaultCustomReporter(); + Message msg = createMessage(); + + @Test + void reportErrorCommandReturnsEmpty() { + reporter.reportError(new RuntimeException(), msg, new Command<>("cmd", "id", "data"), false) + .as(StepVerifier::create) + .verifyComplete(); + } + + @Test + void reportErrorDomainEventReturnsEmpty() { + reporter.reportError(new RuntimeException(), msg, new DomainEvent<>("ev", "id", "data"), false) + .as(StepVerifier::create) + .verifyComplete(); + } + + @Test + void reportErrorAsyncQueryReturnsEmpty() { + reporter.reportError(new RuntimeException(), msg, new AsyncQuery<>("res", "data"), false) + .as(StepVerifier::create) + .verifyComplete(); + } + + @Test + void reportErrorRoutesCommand() { + var command = new Command<>("cmd", "id", "data"); + reporter.reportError(new RuntimeException(), msg, (Object) command, false) + .as(StepVerifier::create) + .verifyComplete(); + } + + @Test + void reportErrorRoutesDomainEvent() { + var event = new DomainEvent<>("ev", "id", "data"); + reporter.reportError(new RuntimeException(), msg, (Object) event, false) + .as(StepVerifier::create) + .verifyComplete(); + } + + @Test + void reportErrorRoutesAsyncQuery() { + var query = new AsyncQuery<>("res", "data"); + reporter.reportError(new RuntimeException(), msg, (Object) query, false) + .as(StepVerifier::create) + .verifyComplete(); + } + + @Test + void reportErrorUnknownTypeReturnsEmpty() { + var result = reporter.reportError(new RuntimeException(), msg, "unknown", false); + assertThatNoException().isThrownBy(() -> result.as(StepVerifier::create).verifyComplete()); + } + + @Test + void reportMetricDoesNothing() { + assertThatNoException().isThrownBy(() -> reporter.reportMetric("type", "path", 100L, true)); + } + + private Message createMessage() { + return new Message() { + @Override + public String getType() { return "test"; } + @Override + public byte[] getBody() { return new byte[0]; } + @Override + public Properties getProperties() { + return new Properties() { + @Override + public String getContentType() { return "application/json"; } + @Override + public long getContentLength() { return 0; } + @Override + public Map getHeaders() { return Map.of(); } + }; + } + }; + } +} diff --git a/async/async-commons/src/test/java/org/reactivecommons/async/commons/utils/resolver/HandlerResolverBuilderTest.java b/async/async-commons/src/test/java/org/reactivecommons/async/commons/utils/resolver/HandlerResolverBuilderTest.java new file mode 100644 index 00000000..e9062ba6 --- /dev/null +++ b/async/async-commons/src/test/java/org/reactivecommons/async/commons/utils/resolver/HandlerResolverBuilderTest.java @@ -0,0 +1,147 @@ +package org.reactivecommons.async.commons.utils.resolver; + +import org.junit.jupiter.api.Test; +import org.reactivecommons.async.api.DefaultCommandHandler; +import org.reactivecommons.async.api.HandlerRegistry; +import reactor.core.publisher.Mono; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class HandlerResolverBuilderTest { + + private final DefaultCommandHandler defaultHandler = command -> Mono.empty(); + + @Test + void buildResolverWithEmptyRegistries() { + var registry = HandlerRegistry.register(); + var resolver = HandlerResolverBuilder.buildResolver("app", Map.of("r1", registry), defaultHandler); + + assertThat(resolver.hasQueryHandlers()).isFalse(); + assertThat(resolver.hasCommandHandlers()).isFalse(); + assertThat(resolver.hasNotificationListeners()).isFalse(); + } + + @Test + void buildResolverWithQueryHandlers() { + var registry = HandlerRegistry.register() + .serveQuery("my.query", msg -> Mono.just("result"), String.class); + + var resolver = HandlerResolverBuilder.buildResolver("app", Map.of("r1", registry), defaultHandler); + + assertThat(resolver.hasQueryHandlers()).isTrue(); + assertThat(resolver.getQueryHandler("my.query")).isNotNull(); + assertThat(resolver.getQueryHandler("my.query").path()).isEqualTo("my.query"); + } + + @Test + void buildResolverWithCommandHandlers() { + var registry = HandlerRegistry.register() + .handleCommand("my.cmd", cmd -> Mono.empty(), String.class); + + var resolver = HandlerResolverBuilder.buildResolver("app", Map.of("r1", registry), defaultHandler); + + assertThat(resolver.hasCommandHandlers()).isTrue(); + assertThat(resolver.getCommandHandler("my.cmd")).isNotNull(); + assertThat(resolver.getCommandHandler("my.cmd").path()).isEqualTo("my.cmd"); + } + + @Test + void buildResolverWithEventListeners() { + var registry = HandlerRegistry.register() + .listenEvent("my.event", evt -> Mono.empty(), String.class); + + var resolver = HandlerResolverBuilder.buildResolver("app", Map.of("r1", registry), defaultHandler); + + assertThat(resolver.getEventListener("my.event")).isNotNull(); + } + + @Test + void buildResolverWithNotificationListeners() { + var registry = HandlerRegistry.register() + .listenNotificationEvent("notif.event", evt -> Mono.empty(), String.class); + + var resolver = HandlerResolverBuilder.buildResolver("app", Map.of("r1", registry), defaultHandler); + + assertThat(resolver.hasNotificationListeners()).isTrue(); + assertThat(resolver.getNotificationListener("notif.event")).isNotNull(); + } + + @Test + void buildResolverWithQueueListeners() { + var registry = HandlerRegistry.register() + .listenQueue("myQueue", msg -> Mono.empty()); + + var resolver = HandlerResolverBuilder.buildResolver("app", Map.of("r1", registry), defaultHandler); + + assertThat(resolver.getQueueListeners()).containsKey("myQueue"); + } + + @Test + void buildResolverWithDynamicEventHandlers() { + var registry = HandlerRegistry.register() + .handleDynamicEvents("event.*", evt -> Mono.empty(), String.class); + + var resolver = HandlerResolverBuilder.buildResolver("app", Map.of("r1", registry), defaultHandler); + + assertThat(resolver.getEventListener("event.foo")).isNotNull(); + } + + @Test + void defaultCommandHandlerReturnedForUnknownPath() { + var registry = HandlerRegistry.register(); + var resolver = HandlerResolverBuilder.buildResolver("app", Map.of("r1", registry), defaultHandler); + + var handler = resolver.getCommandHandler("unknown.command"); + assertThat(handler).isNotNull(); + assertThat(handler.path()).isEmpty(); + } + + @Test + void buildResolverWithMultipleRegistries() { + var registry1 = HandlerRegistry.register() + .serveQuery("q1", msg -> Mono.just("r1"), String.class); + var registry2 = HandlerRegistry.register() + .handleCommand("c1", cmd -> Mono.empty(), String.class); + + var resolver = HandlerResolverBuilder.buildResolver("app", + Map.of("r1", registry1, "r2", registry2), defaultHandler); + + assertThat(resolver.hasQueryHandlers()).isTrue(); + assertThat(resolver.hasCommandHandlers()).isTrue(); + } + + @Test + void buildResolverForCustomDomain() { + var registry = HandlerRegistry.register() + .handleCommand("custom", "domain.cmd", cmd -> Mono.empty(), String.class); + + var resolver = HandlerResolverBuilder.buildResolver("custom", Map.of("r1", registry), defaultHandler); + + assertThat(resolver.hasCommandHandlers()).isTrue(); + assertThat(resolver.getCommandHandler("domain.cmd")).isNotNull(); + } + + @Test + void buildResolverForDomainIgnoresOtherDomainEntries() { + var registry = HandlerRegistry.register() + .handleCommand("other", "other.cmd", cmd -> Mono.empty(), String.class); + + var resolver = HandlerResolverBuilder.buildResolver("app", Map.of("r1", registry), defaultHandler); + + assertThat(resolver.hasCommandHandlers()).isFalse(); + } + + @Test + void buildResolverEventListenersIncludesDynamics() { + var registry = HandlerRegistry.register() + .listenEvent("static.event", evt -> Mono.empty(), String.class) + .handleDynamicEvents("dynamic.*", evt -> Mono.empty(), String.class); + + var resolver = HandlerResolverBuilder.buildResolver("app", Map.of("r1", registry), defaultHandler); + + assertThat(resolver.getEventListener("static.event")).isNotNull(); + assertThat(resolver.getEventListener("dynamic.foo")).isNotNull(); + } +} diff --git a/async/async-rabbit/async-rabbit.gradle b/async/async-rabbit/async-rabbit.gradle index fd887bb1..dde69636 100644 --- a/async/async-rabbit/async-rabbit.gradle +++ b/async/async-rabbit/async-rabbit.gradle @@ -8,10 +8,10 @@ dependencies { api project(':domain-events-api') api project(':async-commons') api project(':cloudevents-json-jackson') + api project(':reactor-rabbitmq') api 'io.projectreactor:reactor-core' api 'io.projectreactor:reactor-core-micrometer' - api 'io.projectreactor.rabbitmq:reactor-rabbitmq:1.5.6' api 'com.rabbitmq:amqp-client' api 'tools.jackson.core:jackson-databind' diff --git a/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/DynamicRegistryImp.java b/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/DynamicRegistryImp.java index fcca072a..2f56b09a 100644 --- a/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/DynamicRegistryImp.java +++ b/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/DynamicRegistryImp.java @@ -10,9 +10,9 @@ import org.reactivecommons.async.api.handlers.registered.RegisteredQueryHandler; import org.reactivecommons.async.commons.HandlerResolver; import org.reactivecommons.async.commons.config.IBrokerConfigProps; +import reactor.rabbitmq.BindingSpecification; import org.reactivecommons.async.rabbit.communications.TopologyCreator; import reactor.core.publisher.Mono; -import reactor.rabbitmq.BindingSpecification; @RequiredArgsConstructor public class DynamicRegistryImp implements DynamicRegistry { diff --git a/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/RabbitDirectAsyncGateway.java b/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/RabbitDirectAsyncGateway.java index 15feb971..6a22a47c 100644 --- a/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/RabbitDirectAsyncGateway.java +++ b/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/RabbitDirectAsyncGateway.java @@ -9,11 +9,11 @@ import org.reactivecommons.async.commons.config.BrokerConfig; import org.reactivecommons.async.commons.converters.MessageConverter; import org.reactivecommons.async.commons.reply.ReactiveReplyRouter; +import reactor.rabbitmq.OutboundMessageResult; import org.reactivecommons.async.rabbit.communications.ReactiveMessageSender; import reactor.core.observability.micrometer.Micrometer; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import reactor.rabbitmq.OutboundMessageResult; import reactor.util.function.Tuple2; import reactor.util.function.Tuples; diff --git a/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/communications/MyOutboundMessage.java b/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/communications/MyOutboundMessage.java deleted file mode 100644 index 5d8e1ffc..00000000 --- a/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/communications/MyOutboundMessage.java +++ /dev/null @@ -1,21 +0,0 @@ -package org.reactivecommons.async.rabbit.communications; - -import com.rabbitmq.client.AMQP; -import lombok.Getter; -import reactor.rabbitmq.OutboundMessage; - -import java.util.function.Consumer; - -@Getter -public class MyOutboundMessage extends OutboundMessage { - - private final Consumer ackNotifier; - - public MyOutboundMessage( - String exchange, String routingKey, AMQP.BasicProperties properties, - byte[] body, Consumer ackNotifier - ) { - super(exchange, routingKey, properties, body); - this.ackNotifier = ackNotifier; - } -} diff --git a/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/communications/ReactiveMessageListener.java b/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/communications/ReactiveMessageListener.java index 00169f4f..0193f911 100644 --- a/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/communications/ReactiveMessageListener.java +++ b/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/communications/ReactiveMessageListener.java @@ -2,7 +2,6 @@ import reactor.rabbitmq.Receiver; - public record ReactiveMessageListener(Receiver receiver, TopologyCreator topologyCreator, Integer maxConcurrency, Integer prefetchCount) { diff --git a/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/communications/ReactiveMessageSender.java b/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/communications/ReactiveMessageSender.java index 954b2148..70d5cfcf 100644 --- a/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/communications/ReactiveMessageSender.java +++ b/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/communications/ReactiveMessageSender.java @@ -40,7 +40,7 @@ public class ReactiveMessageSender { private final UnroutableMessageNotifier unroutableMessageNotifier; private static final int NUMBER_OF_SENDER_SUBSCRIPTIONS = 4; - private final CopyOnWriteArrayList> fluxSinkConfirm = new CopyOnWriteArrayList<>(); + private final CopyOnWriteArrayList> fluxSinkConfirm = new CopyOnWriteArrayList<>(); private volatile FluxSink fluxSinkNoConfirm; private final AtomicLong counter = new AtomicLong(); @@ -66,15 +66,15 @@ public ReactiveMessageSender(Sender sender, String sourceApplication, private void initializeSenders() { for (int i = 0; i < NUMBER_OF_SENDER_SUBSCRIPTIONS; ++i) { - final Flux messageSource = Flux.create(fluxSinkConfirm::add); + final Flux messageSource = Flux.create(fluxSinkConfirm::add); sender.sendWithTypedPublishConfirms(messageSource, new SendOptions().trackReturned(isMandatory)) - .doOnNext((OutboundMessageResult outboundMessageResult) -> { - if (outboundMessageResult.isReturned()) { + .doOnNext((OutboundMessageResult outboundMessageResult) -> { + if (outboundMessageResult.returned()) { this.unroutableMessageNotifier.notifyUnroutableMessage(outboundMessageResult); } final Consumer ackNotifier = - outboundMessageResult.getOutboundMessage().getAckNotifier(); - executorService.submit(() -> ackNotifier.accept(outboundMessageResult.isAck())); + outboundMessageResult.outboundMessage().getAckNotifier(); + executorService.submit(() -> ackNotifier.accept(outboundMessageResult.ack())); }).subscribe(); } @@ -88,7 +88,7 @@ public Mono sendWithConfirm(T message, String exchange, String routing Map headers, boolean persistent) { return Mono.create(monoSink -> { Consumer notifier = new AckNotifier(monoSink); - final MyOutboundMessage outboundMessage = toOutboundMessage( + final OutboundMessage outboundMessage = toOutboundMessage( message, exchange, routingKey, headers, notifier, persistent ); executorService2.submit(() -> fluxSinkConfirm.get( @@ -108,7 +108,7 @@ public Flux sendWithConfirmBatch(Flux messages, St Map headers, boolean persistent) { return messages.map(message -> toOutboundMessage(message, exchange, routingKey, headers, persistent)) .as(sender::sendWithPublishConfirms) - .flatMap(result -> result.isAck() ? + .flatMap(result -> result.ack() ? Mono.empty() : Mono.error(new SendFailureNoAckException("Event no ACK in communications")) ); @@ -126,12 +126,12 @@ public void accept(Boolean ack) { } } - private MyOutboundMessage toOutboundMessage(T object, String exchange, + private OutboundMessage toOutboundMessage(T object, String exchange, String routingKey, Map headers, Consumer ackNotifier, boolean persistent) { final Message message = messageConverter.toMessage(object); final AMQP.BasicProperties props = buildMessageProperties(message, headers, persistent); - return new MyOutboundMessage(exchange, routingKey, props, message.getBody(), ackNotifier); + return new OutboundMessage(exchange, routingKey, props, message.getBody(), ackNotifier); } private OutboundMessage toOutboundMessage(T object, String exchange, String routingKey, diff --git a/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/communications/UnroutableMessageHandler.java b/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/communications/UnroutableMessageHandler.java index e5ea3e5e..fe184abd 100644 --- a/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/communications/UnroutableMessageHandler.java +++ b/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/communications/UnroutableMessageHandler.java @@ -1,6 +1,7 @@ package org.reactivecommons.async.rabbit.communications; import reactor.core.publisher.Mono; +import reactor.rabbitmq.OutboundMessage; import reactor.rabbitmq.OutboundMessageResult; @FunctionalInterface @@ -10,5 +11,5 @@ public interface UnroutableMessageHandler { * * @param result The result of the outbound message, containing the original message and return details. */ - Mono processMessage(OutboundMessageResult result); + Mono processMessage(OutboundMessageResult result); } \ No newline at end of file diff --git a/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/communications/UnroutableMessageNotifier.java b/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/communications/UnroutableMessageNotifier.java index 0c807655..5e2eeaaf 100644 --- a/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/communications/UnroutableMessageNotifier.java +++ b/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/communications/UnroutableMessageNotifier.java @@ -4,18 +4,19 @@ import reactor.core.Disposable; import reactor.core.publisher.Sinks; import reactor.core.scheduler.Schedulers; +import reactor.rabbitmq.OutboundMessage; import reactor.rabbitmq.OutboundMessageResult; @Log public class UnroutableMessageNotifier { - private final Sinks.Many> sink; + private final Sinks.Many> sink; private volatile Disposable currentSubscription; public UnroutableMessageNotifier() { this.sink = Sinks.many().multicast().onBackpressureBuffer(); } - public void notifyUnroutableMessage(OutboundMessageResult message) { + public void notifyUnroutableMessage(OutboundMessageResult message) { if (sink.tryEmitNext(message).isFailure()) { log.warning("Failed to emit unroutable message: " + message); } diff --git a/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/communications/UnroutableMessageProcessor.java b/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/communications/UnroutableMessageProcessor.java index f2ce39b7..6e8a6e4a 100644 --- a/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/communications/UnroutableMessageProcessor.java +++ b/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/communications/UnroutableMessageProcessor.java @@ -3,6 +3,7 @@ import lombok.NoArgsConstructor; import lombok.extern.java.Log; import reactor.core.publisher.Mono; +import reactor.rabbitmq.OutboundMessage; import reactor.rabbitmq.OutboundMessageResult; import java.nio.charset.StandardCharsets; @@ -13,8 +14,8 @@ public class UnroutableMessageProcessor implements UnroutableMessageHandler { @Override - public Mono processMessage(OutboundMessageResult result) { - var outboundMessage = result.getOutboundMessage(); + public Mono processMessage(OutboundMessageResult result) { + var outboundMessage = result.outboundMessage(); log.severe("Unroutable message: exchange=" + outboundMessage.getExchange() + ", routingKey=" + outboundMessage.getRoutingKey() + ", body=" + new String(outboundMessage.getBody(), StandardCharsets.UTF_8) diff --git a/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/listeners/ApplicationNotificationListener.java b/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/listeners/ApplicationNotificationListener.java index 6896bed5..7e28282c 100644 --- a/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/listeners/ApplicationNotificationListener.java +++ b/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/listeners/ApplicationNotificationListener.java @@ -13,16 +13,16 @@ import org.reactivecommons.async.commons.ext.CustomReporter; import org.reactivecommons.async.rabbit.communications.ReactiveMessageListener; import org.reactivecommons.async.rabbit.communications.TopologyCreator; +import reactor.rabbitmq.AcknowledgableDelivery; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import reactor.rabbitmq.AcknowledgableDelivery; import java.util.function.Function; -import static reactor.core.publisher.Flux.fromIterable; import static reactor.rabbitmq.BindingSpecification.binding; import static reactor.rabbitmq.ExchangeSpecification.exchange; import static reactor.rabbitmq.QueueSpecification.queue; +import static reactor.core.publisher.Flux.fromIterable; @Log public class ApplicationNotificationListener extends GenericMessageListener { diff --git a/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/listeners/ApplicationReplyListener.java b/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/listeners/ApplicationReplyListener.java index 5caed0ff..5d560a84 100644 --- a/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/listeners/ApplicationReplyListener.java +++ b/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/listeners/ApplicationReplyListener.java @@ -17,9 +17,10 @@ import static org.reactivecommons.async.commons.Headers.COMPLETION_ONLY_SIGNAL; import static org.reactivecommons.async.commons.Headers.CORRELATION_ID; -import static reactor.rabbitmq.ResourcesSpecification.binding; -import static reactor.rabbitmq.ResourcesSpecification.exchange; -import static reactor.rabbitmq.ResourcesSpecification.queue; +import static reactor.rabbitmq.BindingSpecification.binding; +import static reactor.rabbitmq.ExchangeSpecification.exchange; +import static reactor.rabbitmq.QueueSpecification.queue; + @Log public class ApplicationReplyListener { diff --git a/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/listeners/GenericMessageListener.java b/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/listeners/GenericMessageListener.java index 02c35968..dac167ff 100644 --- a/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/listeners/GenericMessageListener.java +++ b/async/async-rabbit/src/main/java/org/reactivecommons/async/rabbit/listeners/GenericMessageListener.java @@ -9,15 +9,15 @@ import org.reactivecommons.async.commons.utils.LoggerSubscriber; import org.reactivecommons.async.rabbit.InstanceIdentifier; import org.reactivecommons.async.rabbit.RabbitMessage; +import reactor.rabbitmq.AcknowledgableDelivery; +import reactor.rabbitmq.ConsumeOptions; import org.reactivecommons.async.rabbit.communications.ReactiveMessageListener; import org.reactivecommons.async.rabbit.communications.TopologyCreator; +import reactor.rabbitmq.Receiver; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.scheduler.Scheduler; import reactor.core.scheduler.Schedulers; -import reactor.rabbitmq.AcknowledgableDelivery; -import reactor.rabbitmq.ConsumeOptions; -import reactor.rabbitmq.Receiver; import reactor.util.retry.Retry; import java.time.Duration; diff --git a/async/async-rabbit/src/test/java/org/reactivecommons/async/rabbit/DynamicRegistryImpTest.java b/async/async-rabbit/src/test/java/org/reactivecommons/async/rabbit/DynamicRegistryImpTest.java index 2f3ea505..7da7db9a 100644 --- a/async/async-rabbit/src/test/java/org/reactivecommons/async/rabbit/DynamicRegistryImpTest.java +++ b/async/async-rabbit/src/test/java/org/reactivecommons/async/rabbit/DynamicRegistryImpTest.java @@ -1,7 +1,5 @@ package org.reactivecommons.async.rabbit; -import com.rabbitmq.client.AMQP; -import com.rabbitmq.client.AMQP.Queue.BindOk; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -18,7 +16,6 @@ import reactor.core.publisher.Mono; import reactor.rabbitmq.BindingSpecification; import reactor.test.StepVerifier; -import reactor.test.publisher.PublisherProbe; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -62,7 +59,7 @@ void setUp() { @Test void registerEventListener() { setupMock(); - when(topologyCreator.bind(any())).thenReturn(just(mock(BindOk.class))); + when(topologyCreator.bind(any())).thenReturn(Mono.empty()); dynamicRegistry.listenEvent("event1", message -> Mono.empty(), Long.class); final RegisteredEventListener listener = resolver.getEventListener("event1"); @@ -73,7 +70,7 @@ void registerEventListener() { void declareBindingWhenRegisterEventListener() { setupMock(); ArgumentCaptor captor = ArgumentCaptor.forClass(BindingSpecification.class); - when(topologyCreator.bind(any())).thenReturn(just(mock(BindOk.class))); + when(topologyCreator.bind(any())).thenReturn(Mono.empty()); dynamicRegistry.listenEvent("event1", message -> Mono.empty(), Long.class); @@ -87,13 +84,11 @@ void declareBindingWhenRegisterEventListener() { @Test void subscribeToResultWhenRegisterEventListener() { setupMock(); - PublisherProbe probe = PublisherProbe.of(just(mock(BindOk.class))); - when(topologyCreator.bind(any())).thenReturn(probe.mono()); + when(topologyCreator.bind(any())).thenReturn(Mono.empty()); Mono result = dynamicRegistry.listenEvent("event1", message -> Mono.empty(), Long.class); StepVerifier.create(result).verifyComplete(); - probe.assertWasSubscribed(); } @@ -103,10 +98,8 @@ void shouldBindDomainEventsToEventsQueueUsingEventName() { ArgumentCaptor bindingSpecificationCaptor = ArgumentCaptor.forClass(BindingSpecification.class); - PublisherProbe topologyCreatorProbe = PublisherProbe.empty(); - when(topologyCreator.bind(bindingSpecificationCaptor.capture())) - .thenReturn(topologyCreatorProbe.mono()); + .thenReturn(Mono.empty()); String eventName = "a.b.c"; BindingSpecification bindingSpecification = @@ -119,8 +112,6 @@ void shouldBindDomainEventsToEventsQueueUsingEventName() { assertThat(bindingSpecificationCaptor.getValue()) .usingRecursiveComparison() .isEqualTo(bindingSpecification); - - topologyCreatorProbe.assertWasSubscribed(); } @Test @@ -129,10 +120,8 @@ void shouldUnbindDomainEventsToEventsQueueUsingEventName() { ArgumentCaptor bindingSpecificationCaptor = ArgumentCaptor.forClass(BindingSpecification.class); - PublisherProbe topologyCreatorProbe = PublisherProbe.empty(); - when(topologyCreator.unbind(bindingSpecificationCaptor.capture())) - .thenReturn(topologyCreatorProbe.mono()); + .thenReturn(Mono.empty()); String eventName = "a.b.c"; BindingSpecification bindingSpecification = @@ -145,8 +134,6 @@ void shouldUnbindDomainEventsToEventsQueueUsingEventName() { assertThat(bindingSpecificationCaptor.getValue()) .usingRecursiveComparison() .isEqualTo(bindingSpecification); - - topologyCreatorProbe.assertWasSubscribed(); } @Test diff --git a/async/async-rabbit/src/test/java/org/reactivecommons/async/rabbit/communications/MyOutboundMessageTest.java b/async/async-rabbit/src/test/java/org/reactivecommons/async/rabbit/communications/MyOutboundMessageTest.java deleted file mode 100644 index 73fad1a2..00000000 --- a/async/async-rabbit/src/test/java/org/reactivecommons/async/rabbit/communications/MyOutboundMessageTest.java +++ /dev/null @@ -1,45 +0,0 @@ -package org.reactivecommons.async.rabbit.communications; - -import com.rabbitmq.client.AMQP; -import org.junit.jupiter.api.Test; - -import java.util.concurrent.atomic.AtomicBoolean; - -import static org.junit.jupiter.api.Assertions.assertArrayEquals; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class MyOutboundMessageTest { - - @Test - void shouldCreateMessageWithCorrectProperties() { - AMQP.BasicProperties properties = new AMQP.BasicProperties(); - byte[] body = "test message".getBytes(); - AtomicBoolean ackCalled = new AtomicBoolean(false); - - MyOutboundMessage message = new MyOutboundMessage( - "test.exchange", "test.routingKey", properties, body, ackCalled::set - ); - - assertEquals("test.exchange", message.getExchange()); - assertEquals("test.routingKey", message.getRoutingKey()); - assertEquals(properties, message.getProperties()); - assertArrayEquals(body, message.getBody()); - assertNotNull(message.getAckNotifier()); - } - - @Test - void shouldInvokeAckNotifierWhenCalled() { - AtomicBoolean ackCalled = new AtomicBoolean(false); - - MyOutboundMessage message = new MyOutboundMessage( - "test.exchange", "test.routingKey", new AMQP.BasicProperties(), - "test message".getBytes(), ackCalled::set - ); - - message.getAckNotifier().accept(true); - - assertTrue(ackCalled.get()); - } -} \ No newline at end of file diff --git a/async/async-rabbit/src/test/java/org/reactivecommons/async/rabbit/communications/ReactiveMessageSenderTest.java b/async/async-rabbit/src/test/java/org/reactivecommons/async/rabbit/communications/ReactiveMessageSenderTest.java index 7745d471..d0f62822 100644 --- a/async/async-rabbit/src/test/java/org/reactivecommons/async/rabbit/communications/ReactiveMessageSenderTest.java +++ b/async/async-rabbit/src/test/java/org/reactivecommons/async/rabbit/communications/ReactiveMessageSenderTest.java @@ -50,9 +50,9 @@ class ReactiveMessageSenderTest { @BeforeEach void init() { when(sender.sendWithTypedPublishConfirms(any(Publisher.class), any(SendOptions.class))).then(invocation -> { - final Flux argument = invocation.getArgument(0); + final Flux argument = invocation.getArgument(0); return argument - .map(myOutboundMessage -> new OutboundMessageResult<>(myOutboundMessage, true)); + .map(outboundMessage -> new OutboundMessageResult<>(outboundMessage, true)); }); when(sender.send(any(Publisher.class))).then(invocation -> { final Flux argument = invocation.getArgument(0); @@ -69,7 +69,7 @@ void init() { void shouldCallUnroutableMessageHandlerWhenMessageIsReturned() { String sourceApplication = "TestApp"; when(sender.sendWithTypedPublishConfirms(any(Publisher.class), any(SendOptions.class))).then(invocation -> { - Flux argument = invocation.getArgument(0); + Flux argument = invocation.getArgument(0); return argument.map(msg -> new OutboundMessageResult<>(msg, false, true)); }); diff --git a/async/async-rabbit/src/test/java/org/reactivecommons/async/rabbit/communications/UnroutableMessageNotifierTest.java b/async/async-rabbit/src/test/java/org/reactivecommons/async/rabbit/communications/UnroutableMessageNotifierTest.java index 9600529b..9319cbb9 100644 --- a/async/async-rabbit/src/test/java/org/reactivecommons/async/rabbit/communications/UnroutableMessageNotifierTest.java +++ b/async/async-rabbit/src/test/java/org/reactivecommons/async/rabbit/communications/UnroutableMessageNotifierTest.java @@ -12,6 +12,7 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.publisher.Sinks; +import reactor.rabbitmq.OutboundMessage; import reactor.rabbitmq.OutboundMessageResult; import java.lang.reflect.Field; @@ -31,16 +32,16 @@ class UnroutableMessageNotifierTest { private UnroutableMessageNotifier unroutableMessageNotifier; @Mock - private Sinks.Many> sink; + private Sinks.Many> sink; @Mock - private OutboundMessageResult messageResult; + private OutboundMessageResult messageResult; @Mock private UnroutableMessageHandler handler; @Captor - private ArgumentCaptor> messageCaptor; + private ArgumentCaptor> messageCaptor; @BeforeEach void setUp() { @@ -76,7 +77,7 @@ void shouldNotThrowWhenEmissionFails() { @Test void shouldSubscribeHandlerToFluxOfMessages() { - final Flux> messageResultFlux = Flux.just(messageResult); + final Flux> messageResultFlux = Flux.just(messageResult); when(sink.asFlux()).thenReturn(messageResultFlux); when(handler.processMessage(any(OutboundMessageResult.class))).thenReturn(Mono.empty()); @@ -104,7 +105,7 @@ void shouldDisposePreviousSubscriptionWhenNewHandlerIsSubscribed() throws Except @Test void shouldContinueProcessingWhenHandlerFails() { - OutboundMessageResult messageResult2 = mock(OutboundMessageResult.class); + OutboundMessageResult messageResult2 = mock(OutboundMessageResult.class); when(sink.asFlux()).thenReturn(Flux.just(messageResult, messageResult2)); when(handler.processMessage(messageResult)).thenReturn(Mono.error(new RuntimeException("Processing Error"))); when(handler.processMessage(messageResult2)).thenReturn(Mono.empty()); diff --git a/async/async-rabbit/src/test/java/org/reactivecommons/async/rabbit/communications/UnroutableMessageProcessorTest.java b/async/async-rabbit/src/test/java/org/reactivecommons/async/rabbit/communications/UnroutableMessageProcessorTest.java index c9fabe91..fa8d4bba 100644 --- a/async/async-rabbit/src/test/java/org/reactivecommons/async/rabbit/communications/UnroutableMessageProcessorTest.java +++ b/async/async-rabbit/src/test/java/org/reactivecommons/async/rabbit/communications/UnroutableMessageProcessorTest.java @@ -5,6 +5,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import reactor.rabbitmq.OutboundMessage; import reactor.rabbitmq.OutboundMessageResult; import reactor.test.StepVerifier; @@ -17,10 +18,10 @@ class UnroutableMessageProcessorTest { @Mock - private OutboundMessageResult messageResult; + private OutboundMessageResult messageResult; @Mock - private MyOutboundMessage myOutboundMessage; + private OutboundMessage outboundMessage; @InjectMocks private UnroutableMessageProcessor unroutableMessageProcessor; @@ -28,18 +29,18 @@ class UnroutableMessageProcessorTest { @Test void logsUnroutableMessageDetails() { - when(messageResult.getOutboundMessage()).thenReturn(myOutboundMessage); - when(myOutboundMessage.getExchange()).thenReturn("test-exchange"); - when(myOutboundMessage.getRoutingKey()).thenReturn("test-routingKey"); - when(myOutboundMessage.getBody()).thenReturn("test-body".getBytes(StandardCharsets.UTF_8)); - when(myOutboundMessage.getProperties()).thenReturn(null); + when(messageResult.outboundMessage()).thenReturn(outboundMessage); + when(outboundMessage.getExchange()).thenReturn("test-exchange"); + when(outboundMessage.getRoutingKey()).thenReturn("test-routingKey"); + when(outboundMessage.getBody()).thenReturn("test-body".getBytes(StandardCharsets.UTF_8)); + when(outboundMessage.getProperties()).thenReturn(null); StepVerifier.create(unroutableMessageProcessor.processMessage(messageResult)) .verifyComplete(); - verify(messageResult).getOutboundMessage(); - verify(myOutboundMessage).getExchange(); - verify(myOutboundMessage).getRoutingKey(); - verify(myOutboundMessage).getBody(); + verify(messageResult).outboundMessage(); + verify(outboundMessage).getExchange(); + verify(outboundMessage).getRoutingKey(); + verify(outboundMessage).getBody(); } } \ No newline at end of file diff --git a/async/async-rabbit/src/test/java/org/reactivecommons/async/rabbit/listeners/ApplicationReplyListenerTest.java b/async/async-rabbit/src/test/java/org/reactivecommons/async/rabbit/listeners/ApplicationReplyListenerTest.java index 4d41d84d..b6fc33cb 100644 --- a/async/async-rabbit/src/test/java/org/reactivecommons/async/rabbit/listeners/ApplicationReplyListenerTest.java +++ b/async/async-rabbit/src/test/java/org/reactivecommons/async/rabbit/listeners/ApplicationReplyListenerTest.java @@ -79,10 +79,10 @@ void setUp() { .thenReturn(Mono.just(new AMQImpl.Queue.DeclareOk(REPLY_QUEUE, 0, 0))); when(topologyCreator.declare(exchangeCaptor.capture())) - .thenReturn(Mono.just(new AMQImpl.Exchange.DeclareOk())); + .thenReturn(Mono.empty()); when(topologyCreator.bind(bindingCaptor.capture())) - .thenReturn(Mono.just(new AMQImpl.Queue.BindOk())); + .thenReturn(Mono.empty()); } @Test diff --git a/async/async-rabbit/src/test/java/org/reactivecommons/async/rabbit/listeners/ListenerReporterTestSuperClass.java b/async/async-rabbit/src/test/java/org/reactivecommons/async/rabbit/listeners/ListenerReporterTestSuperClass.java index 08d2644d..8286a20a 100644 --- a/async/async-rabbit/src/test/java/org/reactivecommons/async/rabbit/listeners/ListenerReporterTestSuperClass.java +++ b/async/async-rabbit/src/test/java/org/reactivecommons/async/rabbit/listeners/ListenerReporterTestSuperClass.java @@ -75,7 +75,7 @@ public abstract class ListenerReporterTestSuperClass { @BeforeEach void init() { Mockito.when(topologyCreator.declare(any(ExchangeSpecification.class))) - .thenReturn(just(mock(AMQP.Exchange.DeclareOk.class))); + .thenReturn(empty()); Mockito.when(topologyCreator.declareDLQ (any(String.class), any(String.class), any(Integer.class), any(Optional.class)) ) @@ -83,7 +83,7 @@ void init() { Mockito.when(topologyCreator.declareQueue(any(String.class), any(String.class), any(Optional.class))) .thenReturn(just(mock(AMQP.Queue.DeclareOk.class))); Mockito.when(topologyCreator.bind(any(BindingSpecification.class))) - .thenReturn(just(mock(AMQP.Queue.BindOk.class))); + .thenReturn(empty()); } protected void assertContinueAfterSendErrorToCustomReporter(HandlerRegistry handlerRegistry, diff --git a/async/cloudevents-json-jackson/README.md b/async/cloudevents-json-jackson/README.md index fd978020..483eba9d 100644 --- a/async/cloudevents-json-jackson/README.md +++ b/async/cloudevents-json-jackson/README.md @@ -1,6 +1,11 @@ # CloudEvents JSON Jackson Module -This module was obtained from the official CloudEvents SDK Java repository at [https://github.com/cloudevents/sdk-java/blob/main/formats/json-jackson/](https://github.com/cloudevents/sdk-java/blob/main/formats/json-jackson/). +This module is based on the source code from the official CloudEvents SDK Java repository. + +## Original Library + +- **Repository:** [https://github.com/cloudevents/sdk-java](https://github.com/cloudevents/sdk-java) +- **License:** [Apache License 2.0](https://github.com/cloudevents/sdk-java/blob/main/LICENSE) ## Changes from Original diff --git a/async/cloudevents-json-jackson/src/main/java/io/cloudevents/jackson/CloudEventDeserializer.java b/async/cloudevents-json-jackson/src/main/java/io/cloudevents/jackson/CloudEventDeserializer.java index fb88d10f..86088956 100644 --- a/async/cloudevents-json-jackson/src/main/java/io/cloudevents/jackson/CloudEventDeserializer.java +++ b/async/cloudevents-json-jackson/src/main/java/io/cloudevents/jackson/CloudEventDeserializer.java @@ -21,6 +21,7 @@ import io.cloudevents.SpecVersion; import io.cloudevents.core.builder.CloudEventBuilder; import io.cloudevents.core.data.BytesCloudEventData; +import io.cloudevents.rw.CloudEventContextWriter; import io.cloudevents.rw.CloudEventDataMapper; import io.cloudevents.rw.CloudEventRWException; import io.cloudevents.rw.CloudEventReader; @@ -60,14 +61,14 @@ private record JsonMessage(JsonParser p, ObjectNode node, boolean forceExtension boolean forceIgnoreInvalidExtensionNameDeserialization, boolean disableDataContentTypeDefaulting) implements CloudEventReader { + private static final String DATA_BASE64 = "data_base64"; + @Override public , V> V read(CloudEventWriterFactory writerFactory, CloudEventDataMapper mapper) throws CloudEventRWException, IllegalStateException { try { SpecVersion specVersion = SpecVersion.parse(getStringNode(this.node, this.p, "specversion")); CloudEventWriter writer = writerFactory.create(specVersion); - // TODO remove all the unnecessary code specversion aware - // Read mandatory attributes for (String attr : specVersion.getMandatoryAttributes()) { if (!"specversion".equals(attr)) { @@ -76,7 +77,7 @@ public , V> V read(CloudEventWriterFactory w } // Parse datacontenttype if any - String contentType = getOptionalStringNode(this.node, this.p, "datacontenttype"); + String contentType = getOptionalStringNode(this.node, "datacontenttype"); if (!this.disableDataContentTypeDefaulting && contentType == null && this.node.has("data")) { contentType = "application/json"; } @@ -86,105 +87,103 @@ public , V> V read(CloudEventWriterFactory w // Read optional attributes for (String attr : specVersion.getOptionalAttributes()) { - if (!"datacontentencoding".equals(attr)) { // Skip datacontentencoding, we need it later - String val = getOptionalStringNode(this.node, this.p, attr); - if (val != null) { - writer.withContextAttribute(attr, val); - } + String val = getOptionalStringNode(this.node, attr); + if (val != null) { + writer.withContextAttribute(attr, val); } } - CloudEventData data = null; - - // Now let's handle the data (V1 only) - if (node.has("data_base64") && node.has("data")) { - throw MismatchedInputException.from(p, CloudEvent.class, "CloudEvent cannot have both 'data' and 'data_base64' fields"); - } - if (node.has("data_base64")) { - data = BytesCloudEventData.wrap(node.remove("data_base64").binaryValue()); - } else if (node.has("data")) { - if (JsonFormat.dataIsJsonContentType(contentType)) { - // This solution is quite bad, but i see no alternatives now. - // Hopefully in future we can improve it - data = JsonCloudEventData.wrap(node.remove("data")); - } else { - JsonNode dataNode = node.remove("data"); - assertNodeType(dataNode, JsonNodeType.STRING, "data", "Because content type is not a json, only a string is accepted as data"); - data = BytesCloudEventData.wrap(dataNode.asString().getBytes(StandardCharsets.UTF_8)); - } - } - - // Now let's process the extensions - node.properties().forEach(entry -> { - String extensionName = entry.getKey(); - if (this.forceExtensionNameLowerCaseDeserialization) { - extensionName = extensionName.toLowerCase(); - } - - if (this.shouldSkipExtensionName(extensionName)) { - return; - } - - JsonNode extensionValue = entry.getValue(); - - switch (extensionValue.getNodeType()) { - case BOOLEAN: - writer.withContextAttribute(extensionName, extensionValue.booleanValue()); - break; - case NUMBER: - - final Number numericValue = extensionValue.numberValue(); - - // Only 'Int' values are supported by the specification - - if (numericValue instanceof Integer integer) { - writer.withContextAttribute(extensionName, integer); - } else { - throw CloudEventRWException.newInvalidAttributeType(extensionName, numericValue); - } - - break; - case STRING: - writer.withContextAttribute(extensionName, extensionValue.asString()); - break; - default: - writer.withContextAttribute(extensionName, extensionValue.toString()); - } - - }); + CloudEventData data = readData(contentType); + readExtensions(writer); if (data != null) { return writer.end(mapper.map(data)); } return writer.end(); } catch (IllegalArgumentException e) { - throw new RuntimeException(MismatchedInputException.from(this.p, CloudEvent.class, e.getMessage())); + throw MismatchedInputException.from(this.p, CloudEvent.class, e.getMessage()); + } + } + + private CloudEventData readData(String contentType) { + if (node.has(DATA_BASE64) && node.has("data")) { + throw MismatchedInputException.from(p, CloudEvent.class, + "CloudEvent cannot have both 'data' and '" + DATA_BASE64 + "' fields"); + } + if (node.has(DATA_BASE64)) { + return BytesCloudEventData.wrap(node.remove(DATA_BASE64).binaryValue()); + } + if (node.has("data")) { + if (JsonFormat.dataIsJsonContentType(contentType)) { + // This solution is quite bad, but i see no alternatives now. + // Hopefully in future we can improve it + return JsonCloudEventData.wrap(node.remove("data")); + } + JsonNode dataNode = node.remove("data"); + assertNodeIsString(dataNode, "data", "Because content type is not a json, only a string is accepted as data"); + return BytesCloudEventData.wrap(dataNode.asString().getBytes(StandardCharsets.UTF_8)); + } + return null; + } + + private void readExtensions(CloudEventContextWriter writer) { + node.properties().forEach(entry -> { + String extensionName = entry.getKey(); + if (this.forceExtensionNameLowerCaseDeserialization) { + extensionName = extensionName.toLowerCase(); + } + if (this.shouldSkipExtensionName(extensionName)) { + return; + } + writeExtensionAttribute(writer, extensionName, entry.getValue()); + }); + } + + private void writeExtensionAttribute(CloudEventContextWriter writer, String extensionName, JsonNode extensionValue) { + switch (extensionValue.getNodeType()) { + case BOOLEAN: + writer.withContextAttribute(extensionName, extensionValue.booleanValue()); + break; + case NUMBER: + final Number numericValue = extensionValue.numberValue(); + if (numericValue instanceof Integer integer) { + writer.withContextAttribute(extensionName, integer); + } else { + throw CloudEventRWException.newInvalidAttributeType(extensionName, numericValue); + } + break; + case STRING: + writer.withContextAttribute(extensionName, extensionValue.asString()); + break; + default: + writer.withContextAttribute(extensionName, extensionValue.toString()); } } private String getStringNode(ObjectNode objNode, JsonParser p, String attributeName) { - String val = getOptionalStringNode(objNode, p, attributeName); + String val = getOptionalStringNode(objNode, attributeName); if (val == null) { throw MismatchedInputException.from(p, CloudEvent.class, "Missing mandatory " + attributeName + " attribute"); } return val; } - private String getOptionalStringNode(ObjectNode objNode, JsonParser p, String attributeName) { + private String getOptionalStringNode(ObjectNode objNode, String attributeName) { JsonNode unparsedAttribute = objNode.remove(attributeName); if (unparsedAttribute == null || unparsedAttribute instanceof NullNode) { return null; } - assertNodeType(unparsedAttribute, JsonNodeType.STRING, attributeName, null); + assertNodeIsString(unparsedAttribute, attributeName, null); return unparsedAttribute.asString(); } - private void assertNodeType(JsonNode node, JsonNodeType type, String attributeName, String desc) { - if (node.getNodeType() != type) { + private void assertNodeIsString(JsonNode node, String attributeName, String desc) { + if (node.getNodeType() != JsonNodeType.STRING) { throw MismatchedInputException.from( p, CloudEvent.class, - "Wrong type " + node.getNodeType() + " for attribute " + attributeName + ", expecting " + type + (desc != null ? ". " + desc : "") + "Wrong type " + node.getNodeType() + " for attribute " + attributeName + + ", expecting " + JsonNodeType.STRING + (desc != null ? ". " + desc : "") ); } } diff --git a/async/cloudevents-json-jackson/src/main/java/io/cloudevents/jackson/CloudEventSerializer.java b/async/cloudevents-json-jackson/src/main/java/io/cloudevents/jackson/CloudEventSerializer.java index a5109bd3..50795e83 100644 --- a/async/cloudevents-json-jackson/src/main/java/io/cloudevents/jackson/CloudEventSerializer.java +++ b/async/cloudevents-json-jackson/src/main/java/io/cloudevents/jackson/CloudEventSerializer.java @@ -54,12 +54,12 @@ public CloudEventContextWriter withContextAttribute(String name, String value) t @Override @Deprecated(forRemoval = true) public CloudEventContextWriter withContextAttribute(String name, Number value) throws CloudEventRWException { - // Only Integer types are supported by the specification if (value instanceof Integer integer) { - this.withContextAttribute(name, integer); + gen.writeNumberProperty(name, integer); } else { - // Default to string representation for other numeric values - this.withContextAttribute(name, value.toString()); + // Non-integer numeric types (Long, BigDecimal, Float…) are not supported by the + // CloudEvents spec; fall back to their string representation. + gen.writeStringProperty(name, value.toString()); } return this; } diff --git a/async/cloudevents-json-jackson/src/test/java/io/cloudevents/jackson/JsonFormatTest.java b/async/cloudevents-json-jackson/src/test/java/io/cloudevents/jackson/JsonFormatTest.java index 35e925b3..8d6a1bc0 100644 --- a/async/cloudevents-json-jackson/src/test/java/io/cloudevents/jackson/JsonFormatTest.java +++ b/async/cloudevents-json-jackson/src/test/java/io/cloudevents/jackson/JsonFormatTest.java @@ -161,13 +161,13 @@ void throwExpectedOnInvalidSpecversion() { .hasMessageContaining(CloudEventRWException.newInvalidSpecVersion("9000.1").getMessage()); } - @ParameterizedTest - @MethodSource("badJsonContent") /** * JSON content that should fail deserialization * as it represents content that is not CE * specification compliant. */ + @ParameterizedTest + @MethodSource("badJsonContent") void verifyDeserializeError(String inputFile) { byte[] input = loadFile(inputFile); diff --git a/async/reactor-rabbitmq/README.md b/async/reactor-rabbitmq/README.md new file mode 100644 index 00000000..22ecd766 --- /dev/null +++ b/async/reactor-rabbitmq/README.md @@ -0,0 +1,19 @@ +# Reactor RabbitMQ Module + +This module is based on the source code from the archived official [reactor-rabbitmq](https://github.com/spring-attic/reactor-rabbitmq) library by VMware Inc. / Spring Attic. + +## Original Library + +- **Repository:** [https://github.com/spring-attic/reactor-rabbitmq](https://github.com/spring-attic/reactor-rabbitmq) +- **License:** [Apache License 2.0](https://github.com/spring-attic/reactor-rabbitmq/blob/main/LICENSE) + +The original library was archived by its maintainers on September 26, 2025 and is no longer actively maintained. The source code was internalized into this project to allow continued use and maintenance as a dependency. + +## Changes from Original + +- Upgraded from Java 8/11 to Java 17 +- Removed dead code and unused APIs +- `OutboundMessage` extended with optional `ackNotifier` callback, eliminating the need for a separate subclass +- Java 17 modernizations (records, sealed classes patterns, text blocks) +- SLF4J and SonarQube warning fixes +- Adapted for compatibility with the reactive-commons-java project diff --git a/async/reactor-rabbitmq/reactor-rabbitmq.gradle b/async/reactor-rabbitmq/reactor-rabbitmq.gradle new file mode 100644 index 00000000..3e48635f --- /dev/null +++ b/async/reactor-rabbitmq/reactor-rabbitmq.gradle @@ -0,0 +1,9 @@ +ext { + artifactId = 'reactor-rabbitmq' + artifactDescription = 'Reactor RabbitMQ' +} + +dependencies { + api 'io.projectreactor:reactor-core' + api 'com.rabbitmq:amqp-client' +} diff --git a/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/AcknowledgableDelivery.java b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/AcknowledgableDelivery.java new file mode 100644 index 00000000..a55695dc --- /dev/null +++ b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/AcknowledgableDelivery.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2017-2021 VMware Inc. or its affiliates, All Rights Reserved. + * + * 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 reactor.rabbitmq; + +import com.rabbitmq.client.Channel; +import com.rabbitmq.client.Delivery; + +import java.io.IOException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +public class AcknowledgableDelivery extends Delivery { + + private final Channel channel; + private final BiConsumer exceptionHandler; + + private final AtomicBoolean notAckedOrNacked = new AtomicBoolean(true); + + public AcknowledgableDelivery(Delivery delivery, Channel channel, + BiConsumer exceptionHandler) { + super(delivery.getEnvelope(), delivery.getProperties(), delivery.getBody()); + this.channel = channel; + this.exceptionHandler = exceptionHandler; + } + + public void ack(boolean multiple) { + if (notAckedOrNacked.getAndSet(false)) { + try { + basicAck(multiple); + } catch (Exception e) { + retry(e, delivery -> delivery.basicAck(multiple)); + } + } + } + + public void ack() { + ack(false); + } + + public void nack(boolean multiple, boolean requeue) { + if (notAckedOrNacked.getAndSet(false)) { + try { + basicNack(multiple, requeue); + } catch (Exception e) { + retry(e, delivery -> delivery.basicNack(multiple, requeue)); + } + } + } + + public void nack(boolean requeue) { + nack(false, requeue); + } + + private void basicAck(boolean multiple) { + try { + channel.basicAck(getEnvelope().getDeliveryTag(), multiple); + } catch (RuntimeException e) { + throw e; + } catch (IOException e) { + throw new RabbitFluxException(e); + } + } + + private void basicNack(boolean multiple, boolean requeue) { + try { + channel.basicNack(getEnvelope().getDeliveryTag(), multiple, requeue); + } catch (RuntimeException e) { + throw e; + } catch (IOException e) { + throw new RabbitFluxException(e); + } + } + + private void retry(Exception e, Consumer consumer) { + try { + exceptionHandler.accept(new Receiver.AcknowledgmentContext(this, consumer), e); + } catch (Exception e2) { + notAckedOrNacked.set(true); + throw e2; + } + } +} diff --git a/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/BindingSpecification.java b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/BindingSpecification.java new file mode 100644 index 00000000..6d54fefd --- /dev/null +++ b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/BindingSpecification.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2017-2021 VMware Inc. or its affiliates, All Rights Reserved. + * + * 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 reactor.rabbitmq; + + +import lombok.Getter; + +import java.util.Map; + +@Getter +public class BindingSpecification { + + private String queue; + private String exchange; + private String exchangeTo; + private String routingKey; + private Map arguments; + + public static BindingSpecification binding() { + return new BindingSpecification(); + } + + public static BindingSpecification binding(String exchange, String routingKey, String queue) { + return new BindingSpecification().exchange(exchange).routingKey(routingKey).queue(queue); + } + + public static BindingSpecification queueBinding(String exchange, String routingKey, String queue) { + return binding(exchange, routingKey, queue); + } + + public static BindingSpecification exchangeBinding(String exchangeFrom, String routingKey, String exchangeTo) { + return new BindingSpecification().exchangeFrom(exchangeFrom).routingKey(routingKey).exchangeTo(exchangeTo); + } + + public BindingSpecification queue(String queue) { + this.queue = queue; + return this; + } + + public BindingSpecification exchange(String exchange) { + this.exchange = exchange; + return this; + } + + public BindingSpecification exchangeFrom(String exchangeFrom) { + this.exchange = exchangeFrom; + return this; + } + + public BindingSpecification exchangeTo(String exchangeTo) { + this.exchangeTo = exchangeTo; + return this; + } + + public BindingSpecification routingKey(String routingKey) { + this.routingKey = routingKey; + return this; + } + + public BindingSpecification arguments(Map arguments) { + this.arguments = arguments; + return this; + } + +} diff --git a/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/ChannelCloseHandlers.java b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/ChannelCloseHandlers.java new file mode 100644 index 00000000..8e672e59 --- /dev/null +++ b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/ChannelCloseHandlers.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2018-2021 VMware Inc. or its affiliates, All Rights Reserved. + * + * 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 reactor.rabbitmq; + +import com.rabbitmq.client.Channel; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.SignalType; + +import java.util.function.BiConsumer; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ChannelCloseHandlers { + + public static final BiConsumer SENDER_CHANNEL_CLOSE_HANDLER_INSTANCE = + new SenderChannelCloseHandler(); + + public static class SenderChannelCloseHandler implements BiConsumer { + + private static final Logger LOGGER = LoggerFactory.getLogger(SenderChannelCloseHandler.class); + + @Override + public void accept(SignalType signalType, Channel channel) { + int channelNumber = channel.getChannelNumber(); + LOGGER.debug("closing channel {} by signal {}", channelNumber, signalType); + try { + if (channel.isOpen() && channel.getConnection().isOpen()) { + channel.close(); + } + } catch (Exception e) { + LOGGER.warn("Channel {} didn't close normally: {}", channelNumber, e.getMessage()); + } + } + } +} diff --git a/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/ChannelPool.java b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/ChannelPool.java new file mode 100644 index 00000000..0218a720 --- /dev/null +++ b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/ChannelPool.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2019-2021 VMware Inc. or its affiliates, All Rights Reserved. + * + * 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 reactor.rabbitmq; + +import com.rabbitmq.client.Channel; +import reactor.core.publisher.Mono; +import reactor.core.publisher.SignalType; + +import java.util.function.BiConsumer; + +public interface ChannelPool extends AutoCloseable { + + Mono getChannelMono(); + + BiConsumer getChannelCloseHandler(); + + void close(); +} diff --git a/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/ChannelPoolFactory.java b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/ChannelPoolFactory.java new file mode 100644 index 00000000..1e39d3e4 --- /dev/null +++ b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/ChannelPoolFactory.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2019-2021 VMware Inc. or its affiliates, All Rights Reserved. + * + * 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 reactor.rabbitmq; + +import com.rabbitmq.client.Connection; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import reactor.core.publisher.Mono; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ChannelPoolFactory { + + public static ChannelPool createChannelPool(Mono connectionMono) { + return createChannelPool(connectionMono, new ChannelPoolOptions()); + } + + public static ChannelPool createChannelPool(Mono connectionMono, + ChannelPoolOptions channelPoolOptions) { + return new LazyChannelPool(connectionMono, channelPoolOptions); + } +} diff --git a/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/ChannelPoolOptions.java b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/ChannelPoolOptions.java new file mode 100644 index 00000000..1d7d5c45 --- /dev/null +++ b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/ChannelPoolOptions.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2019-2021 VMware Inc. or its affiliates, All Rights Reserved. + * + * 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 reactor.rabbitmq; + +import lombok.Getter; +import reactor.core.scheduler.Scheduler; + +@Getter +public class ChannelPoolOptions { + + private Integer maxCacheSize; + private Scheduler subscriptionScheduler; + + public ChannelPoolOptions maxCacheSize(int maxCacheSize) { + this.maxCacheSize = maxCacheSize; + return this; + } + + public ChannelPoolOptions subscriptionScheduler(Scheduler subscriptionScheduler) { + this.subscriptionScheduler = subscriptionScheduler; + return this; + } + +} diff --git a/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/ChannelProxy.java b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/ChannelProxy.java new file mode 100644 index 00000000..5f7af155 --- /dev/null +++ b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/ChannelProxy.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2018-2021 VMware Inc. or its affiliates, All Rights Reserved. + * + * 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 reactor.rabbitmq; + +import com.rabbitmq.client.Channel; +import com.rabbitmq.client.Connection; +import com.rabbitmq.client.Method; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.io.IOException; +import java.lang.reflect.Proxy; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Factory for creating a {@link Channel} proxy that only supports + * {@link Channel#asyncCompletableRpc(Method)}, re-opening the underlying channel if necessary. + * Used for resource management operations (declare, bind, unbind) in {@link Sender}. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class ChannelProxy { + + /** + * Creates a dynamic proxy implementing {@link Channel} where only + * {@code asyncCompletableRpc} is functional. All other methods throw + * {@link UnsupportedOperationException}. + */ + public static Channel create(Connection connection) throws IOException { + var delegate = new AtomicReference<>(connection.createChannel()); + var lock = new ReentrantLock(); + + return (Channel) Proxy.newProxyInstance( + Channel.class.getClassLoader(), + new Class[]{Channel.class}, + (proxy, method, args) -> switch (method.getName()) { + case "asyncCompletableRpc" -> { + if (!delegate.get().isOpen()) { + lock.lock(); + try { + if (!delegate.get().isOpen()) { + delegate.set(connection.createChannel()); + } + } finally { + lock.unlock(); + } + } + yield delegate.get().asyncCompletableRpc((Method) args[0]); + } + case "toString" -> + "ChannelProxy[delegate=" + delegate.get() + "]"; + default -> throw new UnsupportedOperationException( + "ChannelProxy only supports asyncCompletableRpc, not " + method.getName()); + } + ); + } +} diff --git a/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/ConsumeOptions.java b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/ConsumeOptions.java new file mode 100644 index 00000000..303d80b7 --- /dev/null +++ b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/ConsumeOptions.java @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2017-2021 VMware Inc. or its affiliates, All Rights Reserved. + * + * 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 reactor.rabbitmq; + +import com.rabbitmq.client.Channel; +import com.rabbitmq.client.Delivery; +import lombok.Getter; +import reactor.core.publisher.FluxSink; + +import java.time.Duration; +import java.util.Collections; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.BiPredicate; +import java.util.function.Consumer; + +@Getter +public class ConsumeOptions { + + private int qos = 250; + private String consumerTag = ""; + private FluxSink.OverflowStrategy overflowStrategy = FluxSink.OverflowStrategy.BUFFER; + + private BiPredicate hookBeforeEmitBiFunction = + (requestedFromDownstream, message) -> true; + + private BiPredicate stopConsumingBiFunction = + (requestedFromDownstream, message) -> false; + + private BiConsumer exceptionHandler = + new ExceptionHandlers.RetryAcknowledgmentExceptionHandler( + Duration.ofSeconds(10), Duration.ofMillis(200), + ExceptionHandlers.CONNECTION_RECOVERY_PREDICATE + ); + + private Consumer channelCallback = ch -> { + }; + + private Map arguments = Collections.emptyMap(); + + public ConsumeOptions qos(int qos) { + if (qos < 0) { + throw new IllegalArgumentException("QoS must be greater or equal to 0"); + } + this.qos = qos; + return this; + } + + public ConsumeOptions consumerTag(String consumerTag) { + if (consumerTag == null) { + throw new IllegalArgumentException("consumerTag must be non-null"); + } + this.consumerTag = consumerTag; + return this; + } + + public ConsumeOptions overflowStrategy(FluxSink.OverflowStrategy overflowStrategy) { + this.overflowStrategy = overflowStrategy; + return this; + } + + public ConsumeOptions hookBeforeEmitBiFunction(BiPredicate hookBeforeEmit) { + this.hookBeforeEmitBiFunction = hookBeforeEmit; + return this; + } + + public ConsumeOptions stopConsumingBiFunction(BiPredicate stopConsumingBiFunction) { + this.stopConsumingBiFunction = stopConsumingBiFunction; + return this; + } + + public ConsumeOptions exceptionHandler(BiConsumer exceptionHandler) { + this.exceptionHandler = exceptionHandler; + return this; + } + + public ConsumeOptions channelCallback(Consumer channelCallback) { + this.channelCallback = channelCallback; + return this; + } + + public ConsumeOptions arguments(Map arguments) { + this.arguments = arguments; + return this; + } + +} diff --git a/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/ExceptionHandlers.java b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/ExceptionHandlers.java new file mode 100644 index 00000000..20a4c7dd --- /dev/null +++ b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/ExceptionHandlers.java @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2018-2021 VMware Inc. or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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 reactor.rabbitmq; + +import com.rabbitmq.client.ShutdownSignalException; +import com.rabbitmq.client.impl.recovery.AutorecoveringConnection; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.time.Duration; +import java.util.concurrent.Callable; +import java.util.function.BiConsumer; +import java.util.function.Predicate; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ExceptionHandlers { + + public static final Predicate CONNECTION_RECOVERY_PREDICATE = new ConnectionRecoveryTriggeringPredicate(); + + public static class ConnectionRecoveryTriggeringPredicate implements Predicate { + @Override + public boolean test(Throwable throwable) { + if (throwable instanceof ShutdownSignalException sse) { + return AutorecoveringConnection.DEFAULT_CONNECTION_RECOVERY_TRIGGERING_CONDITION.test(sse); + } + return false; + } + } + + public static class SimpleRetryTemplate { + + private final long timeout; + private final long waitingTime; + private final Predicate predicate; + private final boolean failOnTimeout; + + public SimpleRetryTemplate(Duration timeout, Duration waitingTime, Predicate predicate) { + this(timeout, waitingTime, predicate, true); + } + + public SimpleRetryTemplate(Duration timeout, Duration waitingTime, Predicate predicate, + boolean failOnTimeout) { + if (timeout == null || timeout.isNegative() || timeout.isZero()) { + throw new IllegalArgumentException("Timeout must be greater than 0"); + } + if (waitingTime == null || waitingTime.isNegative() || waitingTime.isZero()) { + throw new IllegalArgumentException("Waiting time must be greater than 0"); + } + if (timeout.compareTo(waitingTime) <= 0) { + throw new IllegalArgumentException("Timeout must be greater than waiting time"); + } + if (predicate == null) { + throw new NullPointerException("Predicate cannot be null"); + } + this.timeout = timeout.toMillis(); + this.waitingTime = waitingTime.toMillis(); + this.predicate = predicate; + this.failOnTimeout = failOnTimeout; + } + + public void retry(Callable operation, Exception e) { + if (predicate.test(e)) { + long elapsedTime = 0; + boolean callSucceeded = false; + while (elapsedTime < timeout) { + try { + Thread.sleep(waitingTime); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new RabbitFluxException("Thread interrupted while retry on sending", ie); + } + elapsedTime += waitingTime; + try { + operation.call(); + callSucceeded = true; + break; + } catch (Exception sendingException) { + if (!predicate.test(sendingException)) { + throw new RabbitFluxException("Not retryable exception thrown during retry", + sendingException); + } + } + } + if (!callSucceeded && failOnTimeout) { + throw new RabbitFluxRetryTimeoutException("Retry timed out after " + this.timeout + " ms", e); + } + } else { + throw new RabbitFluxException("Not retryable exception, cannot retry", e); + } + } + } + + public static class RetryAcknowledgmentExceptionHandler + implements BiConsumer { + + private final SimpleRetryTemplate retryTemplate; + + public RetryAcknowledgmentExceptionHandler(Duration timeout, Duration waitingTime, + Predicate predicate) { + this.retryTemplate = new SimpleRetryTemplate(timeout, waitingTime, predicate, false); + } + + @Override + public void accept(Receiver.AcknowledgmentContext acknowledgmentContext, Exception e) { + retryTemplate.retry(() -> { + acknowledgmentContext.ackOrNack(); + return null; + }, e); + } + } + + public static class RetrySendingExceptionHandler implements BiConsumer { + + private final SimpleRetryTemplate retryTemplate; + + public RetrySendingExceptionHandler(Duration timeout, Duration waitingTime, + Predicate predicate) { + this(timeout, waitingTime, predicate, true); + } + + public RetrySendingExceptionHandler(Duration timeout, Duration waitingTime, + Predicate predicate, boolean failOnTimeout) { + this.retryTemplate = new SimpleRetryTemplate(timeout, waitingTime, predicate, failOnTimeout); + } + + @Override + public void accept(Sender.SendContext sendContext, Exception e) { + retryTemplate.retry(() -> { + sendContext.publish(); + return null; + }, e); + } + } +} diff --git a/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/ExchangeSpecification.java b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/ExchangeSpecification.java new file mode 100644 index 00000000..5cedcf36 --- /dev/null +++ b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/ExchangeSpecification.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2017-2021 VMware Inc. or its affiliates, All Rights Reserved. + * + * 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 reactor.rabbitmq; + +import lombok.Getter; +import java.util.Map; + +@Getter +public class ExchangeSpecification { + + private String name; + private String type = "direct"; + private boolean durable = false; + private boolean autoDelete = false; + private boolean internal = false; + private boolean passive = false; + private Map arguments; + + public static ExchangeSpecification exchange() { + return new ExchangeSpecification(); + } + + public static ExchangeSpecification exchange(String name) { + return new ExchangeSpecification().name(name); + } + + public ExchangeSpecification name(String name) { + this.name = name; + return this; + } + + public ExchangeSpecification type(String type) { + this.type = type; + return this; + } + + public ExchangeSpecification durable(boolean durable) { + this.durable = durable; + return this; + } + + public ExchangeSpecification autoDelete(boolean autoDelete) { + this.autoDelete = autoDelete; + return this; + } + + public ExchangeSpecification internal(boolean internal) { + this.internal = internal; + return this; + } + + public ExchangeSpecification passive(boolean passive) { + this.passive = passive; + return this; + } + + public ExchangeSpecification arguments(Map arguments) { + this.arguments = arguments; + return this; + } +} diff --git a/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/Helpers.java b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/Helpers.java new file mode 100644 index 00000000..eeb138a4 --- /dev/null +++ b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/Helpers.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2019-2021 VMware Inc. or its affiliates, All Rights Reserved. + * + * 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 reactor.rabbitmq; + +import org.slf4j.Logger; + +class Helpers { + + static void safelyExecute(Logger logger, ExceptionRunnable action, String message) { + try { + action.run(); + } catch (Exception e) { + logger.warn("{}: {}", message, e.getMessage()); + } + } + + @FunctionalInterface + interface ExceptionRunnable { + void run() throws Exception; + } +} diff --git a/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/LazyChannelPool.java b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/LazyChannelPool.java new file mode 100644 index 00000000..547e8d11 --- /dev/null +++ b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/LazyChannelPool.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2019-2021 VMware Inc. or its affiliates, All Rights Reserved. + * + * 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 reactor.rabbitmq; + +import com.rabbitmq.client.Channel; +import com.rabbitmq.client.Connection; +import reactor.core.publisher.Mono; +import reactor.core.publisher.SignalType; +import reactor.core.scheduler.Scheduler; +import reactor.core.scheduler.Schedulers; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.function.BiConsumer; + +import static reactor.rabbitmq.ChannelCloseHandlers.SENDER_CHANNEL_CLOSE_HANDLER_INSTANCE; + +class LazyChannelPool implements ChannelPool { + + private static final int DEFAULT_CHANNEL_POOL_SIZE = 5; + + private final Mono connectionMono; + private final BlockingQueue channelsQueue; + private final Scheduler subscriptionScheduler; + + LazyChannelPool(Mono connectionMono, ChannelPoolOptions channelPoolOptions) { + int channelsQueueCapacity = channelPoolOptions.getMaxCacheSize() == null ? + DEFAULT_CHANNEL_POOL_SIZE : channelPoolOptions.getMaxCacheSize(); + this.channelsQueue = new LinkedBlockingQueue<>(channelsQueueCapacity); + this.connectionMono = connectionMono; + this.subscriptionScheduler = channelPoolOptions.getSubscriptionScheduler() == null ? + Schedulers.newBoundedElastic( + Schedulers.DEFAULT_BOUNDED_ELASTIC_SIZE, + Schedulers.DEFAULT_BOUNDED_ELASTIC_QUEUESIZE, + "sender-channel-pool") : + channelPoolOptions.getSubscriptionScheduler(); + } + + public Mono getChannelMono() { + return connectionMono.map(connection -> { + Channel channel = channelsQueue.poll(); + if (channel == null) { + channel = createChannel(connection); + } else { + channel.clearConfirmListeners(); + channel.clearReturnListeners(); + } + return channel; + }).subscribeOn(subscriptionScheduler); + } + + @Override + public BiConsumer getChannelCloseHandler() { + return (signalType, channel) -> { + if (!channel.isOpen()) { + return; + } + boolean offer = signalType == SignalType.ON_COMPLETE && channelsQueue.offer(channel); + if (!offer) { + SENDER_CHANNEL_CLOSE_HANDLER_INSTANCE.accept(signalType, channel); + } + }; + } + + @Override + public void close() { + List channels = new ArrayList<>(); + channelsQueue.drainTo(channels); + channels.forEach(channel -> + SENDER_CHANNEL_CLOSE_HANDLER_INSTANCE.accept(SignalType.ON_COMPLETE, channel) + ); + } + + private Channel createChannel(Connection connection) { + try { + return connection.createChannel(); + } catch (IOException e) { + throw new RabbitFluxException("Error while creating channel", e); + } + } +} diff --git a/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/OutboundMessage.java b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/OutboundMessage.java new file mode 100644 index 00000000..6836b651 --- /dev/null +++ b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/OutboundMessage.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2017-2021 VMware Inc. or its affiliates, All Rights Reserved. + * + * 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 reactor.rabbitmq; + +import com.rabbitmq.client.AMQP.BasicProperties; +import lombok.Getter; +import java.util.Arrays; +import java.util.function.Consumer; + +@Getter +public class OutboundMessage { + + private final String exchange; + private final String routingKey; + private final BasicProperties properties; + private final byte[] body; + private final Consumer ackNotifier; + + private volatile boolean published = false; + + public OutboundMessage(String exchange, String routingKey, byte[] body) { + this(exchange, routingKey, null, body, null); + } + + public OutboundMessage(String exchange, String routingKey, BasicProperties properties, byte[] body) { + this(exchange, routingKey, properties, body, null); + } + + public OutboundMessage(String exchange, String routingKey, BasicProperties properties, byte[] body, + Consumer ackNotifier) { + this.exchange = exchange; + this.routingKey = routingKey; + this.properties = properties; + this.body = body; + this.ackNotifier = ackNotifier; + } + + @Override + public String toString() { + return "OutboundMessage{" + + "exchange='" + exchange + '\'' + + ", routingKey='" + routingKey + '\'' + + ", properties=" + properties + + ", body=" + Arrays.toString(body) + + '}'; + } + + void published() { + this.published = true; + } +} diff --git a/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/OutboundMessageResult.java b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/OutboundMessageResult.java new file mode 100644 index 00000000..7c971f8a --- /dev/null +++ b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/OutboundMessageResult.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2017-2021 VMware Inc. or its affiliates, All Rights Reserved. + * + * 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 reactor.rabbitmq; + +public record OutboundMessageResult(OMSG outboundMessage, boolean ack, boolean returned) { + + public OutboundMessageResult(OMSG outboundMessage, boolean ack) { + this(outboundMessage, ack, false); + } + + @Override + public String toString() { + return "OutboundMessageResult{" + + "outboundMessage=" + outboundMessage + + ", ack=" + ack + + ", returned=" + returned + + '}'; + } +} diff --git a/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/QueueSpecification.java b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/QueueSpecification.java new file mode 100644 index 00000000..4aac2aa1 --- /dev/null +++ b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/QueueSpecification.java @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2017-2021 VMware Inc. or its affiliates, All Rights Reserved. + * + * 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 reactor.rabbitmq; + +import lombok.Getter; + +import java.util.Map; + +@Getter +public class QueueSpecification { + + protected String name; + protected boolean durable = false; + protected boolean exclusive = false; + protected boolean autoDelete = false; + protected boolean passive = false; + protected Map arguments; + + public static QueueSpecification queue() { + return new NullNameQueueSpecification(); + } + + public static QueueSpecification queue(String name) { + return new QueueSpecification().name(name); + } + + public QueueSpecification name(String queue) { + if (queue == null) { + return new NullNameQueueSpecification().arguments(this.arguments); + } + this.name = queue; + return this; + } + + public QueueSpecification durable(boolean durable) { + this.durable = durable; + return this; + } + + public QueueSpecification exclusive(boolean exclusive) { + this.exclusive = exclusive; + return this; + } + + public QueueSpecification autoDelete(boolean autoDelete) { + this.autoDelete = autoDelete; + return this; + } + + public QueueSpecification arguments(Map arguments) { + this.arguments = arguments; + return this; + } + + public QueueSpecification passive(boolean passive) { + this.passive = passive; + return this; + } + + + private static class NullNameQueueSpecification extends QueueSpecification { + + NullNameQueueSpecification() { + this.name = null; + this.durable = false; + this.exclusive = true; + this.autoDelete = true; + this.passive = false; + } + + @Override + public QueueSpecification name(String name) { + if (name == null) { + return this; + } + return QueueSpecification.queue(name) + .durable(durable) + .exclusive(exclusive) + .autoDelete(autoDelete) + .passive(passive); + } + + @Override + public QueueSpecification durable(boolean durable) { + if (this.durable != durable) { + throw new IllegalArgumentException("Once a queue has a null name, durable is always false"); + } + return this; + } + + @Override + public QueueSpecification exclusive(boolean exclusive) { + if (this.exclusive != exclusive) { + throw new IllegalArgumentException("Once a queue has a null name, exclusive is always true"); + } + return this; + } + + @Override + public QueueSpecification autoDelete(boolean autoDelete) { + if (this.autoDelete != autoDelete) { + throw new IllegalArgumentException("Once a queue has a null name, autoDelete is always true"); + } + return this; + } + + @Override + public QueueSpecification passive(boolean passive) { + if (this.passive != passive) { + throw new IllegalArgumentException("Once a queue has a null name, passive is always false"); + } + return this; + } + } +} diff --git a/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/RabbitFluxException.java b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/RabbitFluxException.java new file mode 100644 index 00000000..d69c387e --- /dev/null +++ b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/RabbitFluxException.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2017-2021 VMware Inc. or its affiliates, All Rights Reserved. + * + * 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 reactor.rabbitmq; + + +import lombok.NoArgsConstructor; + +@NoArgsConstructor +public class RabbitFluxException extends RuntimeException { + + public RabbitFluxException(String message) { + super(message); + } + + public RabbitFluxException(String message, Throwable cause) { + super(message, cause); + } + + public RabbitFluxException(Throwable cause) { + super(cause); + } + + public RabbitFluxException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { + super(message, cause, enableSuppression, writableStackTrace); + } +} diff --git a/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/RabbitFluxRetryTimeoutException.java b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/RabbitFluxRetryTimeoutException.java new file mode 100644 index 00000000..0486af59 --- /dev/null +++ b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/RabbitFluxRetryTimeoutException.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2021 VMware, Inc. or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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 reactor.rabbitmq; + +public class RabbitFluxRetryTimeoutException extends RabbitFluxException { + + public RabbitFluxRetryTimeoutException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/Receiver.java b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/Receiver.java new file mode 100644 index 00000000..025b9624 --- /dev/null +++ b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/Receiver.java @@ -0,0 +1,245 @@ +/* + * Copyright (c) 2017-2021 VMware Inc. or its affiliates, All Rights Reserved. + * + * 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 reactor.rabbitmq; + +import com.rabbitmq.client.*; +import com.rabbitmq.client.impl.recovery.AutorecoveringConnection; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; +import reactor.core.publisher.FluxSink; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Scheduler; +import reactor.core.scheduler.Schedulers; + +import java.io.Closeable; +import java.io.IOException; +import java.time.Duration; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.function.Function; + +import static reactor.rabbitmq.Helpers.safelyExecute; + +/** + * Reactive abstraction to consume messages as a {@link Flux}. + */ +public class Receiver implements Closeable { + + private static final Logger LOGGER = LoggerFactory.getLogger(Receiver.class); + + private static final Function CHANNEL_CREATION_FUNCTION = conn -> { + try { + return conn.createChannel(); + } catch (IOException e) { + throw new RabbitFluxException("Error while creating channel", e); + } + }; + + private final Mono connectionMono; + + private final AtomicReference connection = new AtomicReference<>(); + + private final Scheduler connectionSubscriptionScheduler; + + private final boolean privateConnectionSubscriptionScheduler; + + private final int connectionClosingTimeout; + + private final AtomicBoolean closingOrClosed = new AtomicBoolean(false); + + public Receiver() { + this(new ReceiverOptions()); + } + + public Receiver(ReceiverOptions options) { + this.privateConnectionSubscriptionScheduler = options.getConnectionSubscriptionScheduler() == null; + this.connectionSubscriptionScheduler = options.getConnectionSubscriptionScheduler() == null ? + createScheduler("rabbitmq-receiver-connection-subscription") : + options.getConnectionSubscriptionScheduler(); + + Mono cm; + if (options.getConnectionMono() == null) { + cm = Mono.fromCallable(() -> { + if (options.getConnectionSupplier() == null) { + return options.getConnectionFactory().newConnection(); + } else { + return options.getConnectionSupplier().apply(null); + } + }); + cm = options.getConnectionMonoConfigurator().apply(cm); + cm = cm.doOnNext(connection::set) + .subscribeOn(this.connectionSubscriptionScheduler) + .transform(this::cache); + } else { + cm = options.getConnectionMono(); + } + this.connectionMono = cm; + if (options.getConnectionClosingTimeout() != null && !Duration.ZERO.equals(options.getConnectionClosingTimeout())) { + this.connectionClosingTimeout = (int) options.getConnectionClosingTimeout().toMillis(); + } else { + this.connectionClosingTimeout = -1; + } + } + + protected Mono cache(Mono mono) { + return Utils.cache(mono); + } + + protected Scheduler createScheduler(String name) { + return Schedulers.newBoundedElastic( + Schedulers.DEFAULT_BOUNDED_ELASTIC_SIZE, + Schedulers.DEFAULT_BOUNDED_ELASTIC_QUEUESIZE, + name + ); + } + + protected void completeOnChannelShutdown(Channel channel, FluxSink emitter) { + channel.addShutdownListener(reason -> { + if (isRecoverable(channel)) { + // we complete only on "normal" (channels closed by application) for recoverable channels + // other cases includes disconnection, so the channel should recover and resume consuming + if (!AutorecoveringConnection.DEFAULT_CONNECTION_RECOVERY_TRIGGERING_CONDITION.test(reason)) { + emitter.complete(); + } + } else { + // we always complete for non-recoverable channels, because they won't recover by themselves + emitter.complete(); + } + }); + } + + public Flux consumeAutoAck(final String queue) { + return consumeAutoAck(queue, new ConsumeOptions()); + } + + public Flux consumeAutoAck(final String queue, ConsumeOptions options) { + return consumeManualAck(queue, options) + .doOnNext(AcknowledgableDelivery::ack) + .map(ackableMsg -> ackableMsg); + } + + public Flux consumeManualAck(final String queue) { + return consumeManualAck(queue, new ConsumeOptions()); + } + + public Flux consumeManualAck(final String queue, ConsumeOptions options) { + return Flux.create(emitter -> connectionMono.map(CHANNEL_CREATION_FUNCTION).subscribe(channel -> { + try { + if (options.getChannelCallback() != null) { + options.getChannelCallback().accept(channel); + } + if (options.getQos() != 0) { + channel.basicQos(options.getQos()); + } + + DeliverCallback deliverCallback = (consumerTag, message) -> { + AcknowledgableDelivery delivery = new AcknowledgableDelivery(message, channel, options.getExceptionHandler()); + if (options.getHookBeforeEmitBiFunction().test(emitter.requestedFromDownstream(), delivery)) { + emitter.next(delivery); + } + if (options.getStopConsumingBiFunction().test(emitter.requestedFromDownstream(), message)) { + emitter.complete(); + } + }; + + AtomicBoolean basicCancel = new AtomicBoolean(true); + CancelCallback cancelCallback = consumerTag -> { + LOGGER.info("Flux consumer {} has been cancelled", consumerTag); + basicCancel.set(false); + emitter.complete(); + }; + + completeOnChannelShutdown(channel, emitter); + + final String consumerTag = channel.basicConsume( + queue, + false, // no auto-ack + options.getConsumerTag(), + false, // noLocal (not supported by RabbitMQ) + false, // not exclusive + options.getArguments(), + deliverCallback, + cancelCallback); + AtomicBoolean cancelled = new AtomicBoolean(false); + LOGGER.info("Consumer {} consuming from {} has been registered", consumerTag, queue); + emitter.onDispose(() -> { + LOGGER.info("Cancelling consumer {} consuming from {}", consumerTag, queue); + if (cancelled.compareAndSet(false, true)) { + try { + if (channel.isOpen() && channel.getConnection().isOpen()) { + if (basicCancel.compareAndSet(true, false)) { + channel.basicCancel(consumerTag); + } + channel.close(); + } + } catch (TimeoutException | IOException e) { + LOGGER.warn("Error while closing channel: {}", e.getMessage()); + } + } + }); + } catch (Exception e) { + emitter.error(new RabbitFluxException(e)); + } + }, emitter::error), options.getOverflowStrategy()); + } + + protected boolean isRecoverable(Connection connection) { + return Utils.isRecoverable(connection); + } + + protected boolean isRecoverable(Channel channel) { + return Utils.isRecoverable(channel); + } + + public void close() { + if (closingOrClosed.compareAndSet(false, true)) { + if (connection.get() != null) { + safelyExecute( + LOGGER, + () -> connection.get().close(this.connectionClosingTimeout), + "Error while closing receiver connection" + ); + } + if (privateConnectionSubscriptionScheduler) { + safelyExecute( + LOGGER, + this.connectionSubscriptionScheduler::dispose, + "Error while disposing connection subscriber scheduler" + ); + } + } + } + + public static class AcknowledgmentContext { + + private final AcknowledgableDelivery delivery; + private final Consumer consumer; + + public AcknowledgmentContext(AcknowledgableDelivery delivery, Consumer consumer) { + this.delivery = delivery; + this.consumer = consumer; + } + + public void ackOrNack() { + consumer.accept(delivery); + } + } + +} diff --git a/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/ReceiverOptions.java b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/ReceiverOptions.java new file mode 100644 index 00000000..d198d5a2 --- /dev/null +++ b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/ReceiverOptions.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2017-2021 VMware Inc. or its affiliates, All Rights Reserved. + * + * 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 reactor.rabbitmq; + +import com.rabbitmq.client.Connection; +import com.rabbitmq.client.ConnectionFactory; +import lombok.Getter; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Scheduler; + +import java.time.Duration; +import java.util.function.UnaryOperator; + +@Getter +public class ReceiverOptions { + + private ConnectionFactory connectionFactory = new ConnectionFactory(); + + private Mono connectionMono; + private Scheduler connectionSubscriptionScheduler; + private Utils.ExceptionFunction connectionSupplier; + private UnaryOperator> connectionMonoConfigurator = cm -> cm; + private Duration connectionClosingTimeout = Duration.ofSeconds(30); + + public ReceiverOptions connectionFactory(ConnectionFactory connectionFactory) { + this.connectionFactory = connectionFactory; + return this; + } + + public ReceiverOptions connectionSubscriptionScheduler(Scheduler connectionSubscriptionScheduler) { + this.connectionSubscriptionScheduler = connectionSubscriptionScheduler; + return this; + } + + public ReceiverOptions connectionSupplier( + Utils.ExceptionFunction function) { + return this.connectionSupplier(this.connectionFactory, function); + } + + public ReceiverOptions connectionSupplier(ConnectionFactory connectionFactory, + Utils.ExceptionFunction function) { + this.connectionSupplier = ignored -> function.apply(connectionFactory); + return this; + } + + public ReceiverOptions connectionMono(Mono connectionMono) { + this.connectionMono = connectionMono; + return this; + } + + public ReceiverOptions connectionMonoConfigurator( + UnaryOperator> connectionMonoConfigurator) { + this.connectionMonoConfigurator = connectionMonoConfigurator; + return this; + } + + public ReceiverOptions connectionClosingTimeout(Duration connectionClosingTimeout) { + this.connectionClosingTimeout = connectionClosingTimeout; + return this; + } + +} diff --git a/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/SendOptions.java b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/SendOptions.java new file mode 100644 index 00000000..84fde5f2 --- /dev/null +++ b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/SendOptions.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2018-2021 VMware Inc. or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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 reactor.rabbitmq; + +import com.rabbitmq.client.Channel; +import lombok.Getter; +import reactor.core.publisher.Mono; +import reactor.core.publisher.SignalType; +import reactor.core.scheduler.Scheduler; +import reactor.core.scheduler.Schedulers; + +import java.time.Duration; +import java.util.function.BiConsumer; + +@Getter +public class SendOptions { + + private BiConsumer exceptionHandler = new ExceptionHandlers.RetrySendingExceptionHandler( + Duration.ofSeconds(10), Duration.ofMillis(200), + ExceptionHandlers.CONNECTION_RECOVERY_PREDICATE + ); + + private Integer maxInFlight; + private boolean trackReturned; + private Scheduler scheduler = Schedulers.immediate(); + private Mono channelMono; + private BiConsumer channelCloseHandler; + + public SendOptions trackReturned(boolean trackReturned) { + this.trackReturned = trackReturned; + return this; + } + + public SendOptions maxInFlight(int maxInFlight) { + this.maxInFlight = maxInFlight; + return this; + } + + public SendOptions maxInFlight(int maxInFlight, Scheduler scheduler) { + this.maxInFlight = maxInFlight; + this.scheduler = scheduler; + return this; + } + + public SendOptions exceptionHandler(BiConsumer exceptionHandler) { + this.exceptionHandler = exceptionHandler; + return this; + } + + public SendOptions channelMono(Mono channelMono) { + this.channelMono = channelMono; + return this; + } + + public SendOptions channelCloseHandler(BiConsumer channelCloseHandler) { + this.channelCloseHandler = channelCloseHandler; + return this; + } + + public SendOptions channelPool(ChannelPool channelPool) { + this.channelMono = channelPool.getChannelMono(); + this.channelCloseHandler = channelPool.getChannelCloseHandler(); + return this; + } +} diff --git a/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/Sender.java b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/Sender.java new file mode 100644 index 00000000..62931bc6 --- /dev/null +++ b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/Sender.java @@ -0,0 +1,672 @@ +/* + * Copyright (c) 2017-2021 VMware Inc. or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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 reactor.rabbitmq; + +import com.rabbitmq.client.AMQP; +import com.rabbitmq.client.Channel; +import com.rabbitmq.client.ConfirmListener; +import com.rabbitmq.client.Connection; +import com.rabbitmq.client.ReturnListener; +import com.rabbitmq.client.ShutdownListener; +import com.rabbitmq.client.impl.AMQImpl; +import org.reactivestreams.Publisher; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.CoreSubscriber; +import reactor.core.publisher.Flux; +import reactor.core.publisher.FluxOperator; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Operators; +import reactor.core.publisher.SignalType; +import reactor.core.scheduler.Scheduler; +import reactor.core.scheduler.Schedulers; + +import java.io.IOException; +import java.time.Duration; +import java.util.HashMap; +import java.util.Objects; +import java.util.concurrent.ConcurrentNavigableMap; +import java.util.concurrent.ConcurrentSkipListMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.stream.Stream; + +import static reactor.rabbitmq.Helpers.safelyExecute; + +/** + * Reactive abstraction to create resources and send messages. + */ +public class Sender implements AutoCloseable { + + private static final Logger LOGGER = LoggerFactory.getLogger(Sender.class); + + private static final Function CHANNEL_CREATION_FUNCTION = conn -> { + try { + return conn.createChannel(); + } catch (IOException e) { + throw new RabbitFluxException("Error while creating channel", e); + } + }; + + private static final Function CHANNEL_PROXY_CREATION_FUNCTION = conn -> { + try { + return ChannelProxy.create(conn); + } catch (IOException e) { + throw new RabbitFluxException("Error while creating channel", e); + } + }; + + private final Mono connectionMono; + + private final Mono channelMono; + + private final BiConsumer channelCloseHandler; + + private final AtomicReference connection = new AtomicReference<>(); + + private final Mono resourceManagementChannelMono; + + private final Scheduler resourceManagementScheduler; + + private final boolean privateResourceManagementScheduler; + + private final Scheduler connectionSubscriptionScheduler; + + private final boolean privateConnectionSubscriptionScheduler; + + private final ExecutorService channelCloseThreadPool = Executors.newCachedThreadPool(); + + private final int connectionClosingTimeout; + + private final AtomicBoolean closingOrClosed = new AtomicBoolean(false); + + private static final String REACTOR_RABBITMQ_DELIVERY_TAG_HEADER = "reactor_rabbitmq_delivery_tag"; + + public Sender() { + this(new SenderOptions()); + } + + public Sender(SenderOptions options) { + this.privateConnectionSubscriptionScheduler = options.getConnectionSubscriptionScheduler() == null; + this.connectionSubscriptionScheduler = options.getConnectionSubscriptionScheduler() == null + ? createScheduler("rabbitmq-sender-connection-subscription") + : options.getConnectionSubscriptionScheduler(); + + Mono cm; + if (options.getConnectionMono() == null) { + cm = Mono.fromCallable(() -> { + if (options.getConnectionSupplier() == null) { + return options.getConnectionFactory().newConnection(); + } else { + return options.getConnectionSupplier().apply(null); + } + }); + cm = options.getConnectionMonoConfigurator().apply(cm); + cm = cm.doOnNext(connection::set) + .subscribeOn(this.connectionSubscriptionScheduler) + .transform(this::cache); + } else { + cm = options.getConnectionMono(); + } + + this.connectionMono = cm; + this.channelMono = options.getChannelMono(); + this.channelCloseHandler = options.getChannelCloseHandler() == null + ? ChannelCloseHandlers.SENDER_CHANNEL_CLOSE_HANDLER_INSTANCE + : options.getChannelCloseHandler(); + this.privateResourceManagementScheduler = options.getResourceManagementScheduler() == null; + this.resourceManagementScheduler = options.getResourceManagementScheduler() == null + ? createScheduler("rabbitmq-sender-resource-creation") + : options.getResourceManagementScheduler(); + this.resourceManagementChannelMono = options.getResourceManagementChannelMono() == null + ? connectionMono.map(CHANNEL_PROXY_CREATION_FUNCTION).transform(this::cache) + : options.getResourceManagementChannelMono(); + if (options.getConnectionClosingTimeout() != null && !Duration.ZERO.equals(options.getConnectionClosingTimeout())) { + this.connectionClosingTimeout = (int) options.getConnectionClosingTimeout().toMillis(); + } else { + this.connectionClosingTimeout = -1; + } + } + + protected Scheduler createScheduler(String name) { + return Schedulers.newBoundedElastic( + Schedulers.DEFAULT_BOUNDED_ELASTIC_SIZE, + Schedulers.DEFAULT_BOUNDED_ELASTIC_QUEUESIZE, + name + ); + } + + protected Mono cache(Mono mono) { + return Utils.cache(mono); + } + + // --- Send --- + + public Mono send(Publisher messages) { + return send(messages, new SendOptions()); + } + + public Mono send(Publisher messages, SendOptions options) { + var opts = options == null ? new SendOptions() : options; + final Mono currentChannelMono = getChannelMono(opts); + final BiConsumer exceptionHandler = opts.getExceptionHandler(); + final BiConsumer closeHandler = getChannelCloseHandler(opts); + + return currentChannelMono.flatMapMany(channel -> + Flux.from(messages) + .doOnNext(message -> { + try { + channel.basicPublish( + message.getExchange(), + message.getRoutingKey(), + message.getProperties(), + message.getBody() + ); + } catch (Exception e) { + exceptionHandler.accept(new SendContext<>(channel, message), e); + } + }) + .doOnError(e -> LOGGER.warn("Send failed with exception", e)) + .doFinally(st -> closeHandler.accept(st, channel)) + ).then(); + } + + // --- Publish Confirms --- + + public Flux sendWithPublishConfirms(Publisher messages) { + return sendWithPublishConfirms(messages, new SendOptions()); + } + + public Flux sendWithPublishConfirms(Publisher messages, SendOptions options) { + return Flux.from(sendWithTypedPublishConfirms(messages, options)); + } + + public Flux> sendWithTypedPublishConfirms(Publisher messages) { + return sendWithTypedPublishConfirms(messages, new SendOptions()); + } + + public Flux> sendWithTypedPublishConfirms( + Publisher messages, SendOptions options) { + var sendOptions = options == null ? new SendOptions() : options; + final Mono currentChannelMono = getChannelMono(sendOptions); + final BiConsumer closeHandler = getChannelCloseHandler(sendOptions); + + Flux> result = currentChannelMono.map(channel -> { + try { + channel.confirmSelect(); + } catch (IOException e) { + throw new RabbitFluxException("Error while setting publisher confirms on channel", e); + } + return channel; + }).flatMapMany(channel -> new PublishConfirmOperator<>(messages, channel, sendOptions).doFinally(signalType -> { + if (signalType == SignalType.ON_ERROR) { + closeHandler.accept(signalType, channel); + } else { + channelCloseThreadPool.execute(() -> closeHandler.accept(signalType, channel)); + } + })); + + if (sendOptions.getMaxInFlight() != null) { + result = result.publishOn(sendOptions.getScheduler(), sendOptions.getMaxInFlight()); + } + return result; + } + + // package-protected for testing + Mono getChannelMono(SendOptions options) { + return Stream.of(options.getChannelMono(), channelMono) + .filter(Objects::nonNull) + .findFirst().orElse(connectionMono.map(CHANNEL_CREATION_FUNCTION)); + } + + private BiConsumer getChannelCloseHandler(SendOptions options) { + return options.getChannelCloseHandler() != null + ? options.getChannelCloseHandler() : this.channelCloseHandler; + } + + // --- Resource Management (declare / bind / unbind) --- + + public Mono declare(QueueSpecification specification) { + AMQP.Queue.Declare declare; + if (specification.getName() == null) { + declare = new AMQImpl.Queue.Declare.Builder() + .queue("") + .durable(false) + .exclusive(true) + .autoDelete(true) + .arguments(specification.getArguments()) + .build(); + } else { + declare = new AMQImpl.Queue.Declare.Builder() + .queue(specification.getName()) + .durable(specification.isDurable()) + .exclusive(specification.isExclusive()) + .autoDelete(specification.isAutoDelete()) + .passive(specification.isPassive()) + .arguments(specification.getArguments()) + .build(); + } + + return resourceManagementChannelMono.map(channel -> { + try { + return channel.asyncCompletableRpc(declare); + } catch (IOException e) { + throw new RabbitFluxException("Error during RPC call", e); + } + }).flatMap(Mono::fromCompletionStage) + .map(command -> (AMQP.Queue.DeclareOk) command.getMethod()) + .publishOn(resourceManagementScheduler); + } + + public Mono declare(ExchangeSpecification specification) { + AMQP.Exchange.Declare declare = new AMQImpl.Exchange.Declare.Builder() + .exchange(specification.getName()) + .type(specification.getType()) + .durable(specification.isDurable()) + .autoDelete(specification.isAutoDelete()) + .internal(specification.isInternal()) + .passive(specification.isPassive()) + .arguments(specification.getArguments()) + .build(); + + return resourceManagementChannelMono.map(channel -> { + try { + return channel.asyncCompletableRpc(declare); + } catch (IOException e) { + throw new RabbitFluxException("Error during RPC call", e); + } + }).flatMap(Mono::fromCompletionStage) + .map(command -> (AMQP.Exchange.DeclareOk) command.getMethod()) + .publishOn(resourceManagementScheduler); + } + + public Mono bind(BindingSpecification specification) { + AMQP.Queue.Bind binding = new AMQImpl.Queue.Bind.Builder() + .exchange(specification.getExchange()) + .queue(specification.getQueue()) + .routingKey(specification.getRoutingKey()) + .arguments(specification.getArguments()) + .build(); + + return resourceManagementChannelMono.map(channel -> { + try { + return channel.asyncCompletableRpc(binding); + } catch (IOException e) { + throw new RabbitFluxException("Error during RPC call", e); + } + }).flatMap(Mono::fromCompletionStage) + .map(command -> (AMQP.Queue.BindOk) command.getMethod()) + .publishOn(resourceManagementScheduler); + } + + public Mono unbind(BindingSpecification specification) { + AMQP.Queue.Unbind unbinding = new AMQImpl.Queue.Unbind.Builder() + .exchange(specification.getExchange()) + .queue(specification.getQueue()) + .routingKey(specification.getRoutingKey()) + .arguments(specification.getArguments()) + .build(); + + return resourceManagementChannelMono.map(channel -> { + try { + return channel.asyncCompletableRpc(unbinding); + } catch (IOException e) { + throw new RabbitFluxException("Error during RPC call", e); + } + }).flatMap(Mono::fromCompletionStage) + .map(command -> (AMQP.Queue.UnbindOk) command.getMethod()) + .publishOn(resourceManagementScheduler); + } + + // --- Lifecycle --- + + @Override + public void close() { + if (closingOrClosed.compareAndSet(false, true)) { + if (connection.get() != null) { + safelyExecute(LOGGER, () -> connection.get().close(this.connectionClosingTimeout), + "Error while closing sender connection"); + } + if (this.privateConnectionSubscriptionScheduler) { + safelyExecute(LOGGER, this.connectionSubscriptionScheduler::dispose, + "Error while disposing connection subscription scheduler"); + } + if (this.privateResourceManagementScheduler) { + safelyExecute(LOGGER, this.resourceManagementScheduler::dispose, + "Error while disposing resource management scheduler"); + } + safelyExecute(LOGGER, channelCloseThreadPool::shutdown, + "Error while closing channel closing thread pool"); + } + } + + // --- Inner Classes --- + + public static class SendContext { + + protected final Channel channel; + protected final OMSG message; + + protected SendContext(Channel channel, OMSG message) { + this.channel = channel; + this.message = message; + } + + public OMSG getMessage() { + return message; + } + + public Channel getChannel() { + return channel; + } + + public void publish(OutboundMessage outboundMessage) throws Exception { + this.channel.basicPublish( + outboundMessage.getExchange(), + outboundMessage.getRoutingKey(), + outboundMessage.getProperties(), + outboundMessage.getBody() + ); + } + + public void publish() throws Exception { + this.publish(getMessage()); + } + } + + public static class ConfirmSendContext extends SendContext { + + private final PublishConfirmSubscriber subscriber; + + protected ConfirmSendContext(Channel channel, OMSG message, PublishConfirmSubscriber subscriber) { + super(channel, message); + this.subscriber = subscriber; + } + + @Override + public void publish(OutboundMessage outboundMessage) throws Exception { + long nextPublishSeqNo = channel.getNextPublishSeqNo(); + try { + subscriber.unconfirmed.putIfAbsent(nextPublishSeqNo, this.message); + this.channel.basicPublish( + outboundMessage.getExchange(), + outboundMessage.getRoutingKey(), + this.subscriber.trackReturned, + this.subscriber.propertiesProcessor.apply(message.getProperties(), nextPublishSeqNo), + outboundMessage.getBody() + ); + } catch (Exception e) { + subscriber.unconfirmed.remove(nextPublishSeqNo); + throw e; + } + } + + @Override + public void publish() throws Exception { + this.publish(getMessage()); + } + } + + private static class PublishConfirmOperator + extends FluxOperator> { + + private final Channel channel; + private final SendOptions options; + + PublishConfirmOperator(Publisher source, Channel channel, SendOptions options) { + super(Flux.from(source)); + this.channel = channel; + this.options = options; + } + + @Override + public void subscribe(CoreSubscriber> actual) { + source.subscribe(new PublishConfirmSubscriber<>(channel, actual, options)); + } + } + + static class PublishConfirmSubscriber implements + CoreSubscriber, Subscription { + + private final AtomicReference state = new AtomicReference<>(SubscriberState.INIT); + private final AtomicReference firstException = new AtomicReference<>(); + final ConcurrentNavigableMap unconfirmed = new ConcurrentSkipListMap<>(); + + private final Channel channel; + private final Subscriber> subscriber; + private final BiConsumer exceptionHandler; + + private Subscription subscription; + private ConfirmListener confirmListener; + private ReturnListener returnListener; + private ShutdownListener shutdownListener; + + final boolean trackReturned; + final BiFunction propertiesProcessor; + + PublishConfirmSubscriber(Channel channel, Subscriber> subscriber, + SendOptions options) { + this.channel = channel; + this.subscriber = subscriber; + this.exceptionHandler = options.getExceptionHandler(); + this.trackReturned = options.isTrackReturned(); + this.propertiesProcessor = this.trackReturned + ? PublishConfirmSubscriber::addReactorRabbitMQDeliveryTag + : (properties, deliveryTag) -> properties; + } + + @Override + public void request(long n) { + subscription.request(n); + } + + @Override + public void cancel() { + subscription.cancel(); + } + + @Override + public void onSubscribe(Subscription subscription) { + if (Operators.validate(this.subscription, subscription)) { + + if (this.trackReturned) { + this.returnListener = (replyCode, replyText, exchange, routingKey, properties, body) -> { + try { + Object deliveryTagObj = properties.getHeaders().get(REACTOR_RABBITMQ_DELIVERY_TAG_HEADER); + if (deliveryTagObj instanceof Long deliveryTag) { + OMSG outboundMessage = unconfirmed.get(deliveryTag); + subscriber.onNext(new OutboundMessageResult<>(outboundMessage, true, true)); + unconfirmed.remove(deliveryTag); + } else { + handleError(new IllegalArgumentException( + "Missing header " + REACTOR_RABBITMQ_DELIVERY_TAG_HEADER), null); + } + } catch (Exception e) { + handleError(e, null); + } + }; + channel.addReturnListener(this.returnListener); + } + + this.confirmListener = new ConfirmListener() { + + @Override + public void handleAck(long deliveryTag, boolean multiple) { + handleAckNack(deliveryTag, multiple, true); + } + + @Override + public void handleNack(long deliveryTag, boolean multiple) { + handleAckNack(deliveryTag, multiple, false); + } + + private void handleAckNack(long deliveryTag, boolean multiple, boolean ack) { + if (multiple) { + try { + var unconfirmedToSend = unconfirmed.headMap(deliveryTag, true); + var iterator = unconfirmedToSend.entrySet().iterator(); + while (iterator.hasNext()) { + subscriber.onNext(new OutboundMessageResult<>( + iterator.next().getValue(), ack, false)); + iterator.remove(); + } + } catch (Exception e) { + handleError(e, null); + } + } else { + OMSG outboundMessage = unconfirmed.get(deliveryTag); + if (outboundMessage != null) { + try { + unconfirmed.remove(deliveryTag); + subscriber.onNext(new OutboundMessageResult<>(outboundMessage, ack, false)); + } catch (Exception e) { + handleError(e, new OutboundMessageResult<>(outboundMessage, ack, false)); + } + } + } + if (unconfirmed.isEmpty()) { + maybeComplete(); + } + } + }; + channel.addConfirmListener(confirmListener); + + this.shutdownListener = sse -> { + var iterator = this.unconfirmed.entrySet().iterator(); + while (iterator.hasNext()) { + OMSG message = iterator.next().getValue(); + if (!message.isPublished()) { + try { + subscriber.onNext(new OutboundMessageResult<>(message, false, false)); + iterator.remove(); + } catch (Exception e) { + LOGGER.info("Error while nacking messages after channel failure"); + } + } + } + if (!sse.isHardError() && !sse.isInitiatedByApplication()) { + subscriber.onError(sse); + } + }; + channel.addShutdownListener(shutdownListener); + + state.set(SubscriberState.ACTIVE); + this.subscription = subscription; + subscriber.onSubscribe(this); + } + } + + @Override + public void onNext(OMSG message) { + if (checkComplete(message)) { + return; + } + long nextPublishSeqNo = channel.getNextPublishSeqNo(); + try { + unconfirmed.putIfAbsent(nextPublishSeqNo, message); + channel.basicPublish( + message.getExchange(), + message.getRoutingKey(), + this.trackReturned, + this.propertiesProcessor.apply(message.getProperties(), nextPublishSeqNo), + message.getBody() + ); + message.published(); + } catch (Exception e) { + unconfirmed.remove(nextPublishSeqNo); + try { + this.exceptionHandler.accept(new ConfirmSendContext<>(channel, message, this), e); + } catch (RabbitFluxRetryTimeoutException timeoutException) { + subscriber.onNext(new OutboundMessageResult<>(message, false, false)); + } catch (Exception innerException) { + handleError(innerException, new OutboundMessageResult<>(message, false, false)); + } + } + } + + private static AMQP.BasicProperties addReactorRabbitMQDeliveryTag( + AMQP.BasicProperties properties, long deliveryTag) { + var baseProperties = properties != null ? properties : new AMQP.BasicProperties(); + var headers = baseProperties.getHeaders() != null + ? new HashMap<>(baseProperties.getHeaders()) : new HashMap(); + headers.putIfAbsent(REACTOR_RABBITMQ_DELIVERY_TAG_HEADER, deliveryTag); + return baseProperties.builder().headers(headers).build(); + } + + @Override + public void onError(Throwable throwable) { + if (state.compareAndSet(SubscriberState.ACTIVE, SubscriberState.COMPLETE) + || state.compareAndSet(SubscriberState.OUTBOUND_DONE, SubscriberState.COMPLETE)) { + channel.removeConfirmListener(confirmListener); + channel.removeShutdownListener(shutdownListener); + if (returnListener != null) { + channel.removeReturnListener(returnListener); + } + subscriber.onError(throwable); + } else if (firstException.compareAndSet(null, throwable) && state.get() == SubscriberState.COMPLETE) { + Operators.onErrorDropped(throwable, currentContext()); + } + } + + @Override + public void onComplete() { + if (state.compareAndSet(SubscriberState.ACTIVE, SubscriberState.OUTBOUND_DONE) + && unconfirmed.isEmpty()) { + maybeComplete(); + } + } + + private void handleError(Exception e, OutboundMessageResult result) { + LOGGER.error("error in publish confirm sending", e); + boolean complete = checkComplete(e); + firstException.compareAndSet(null, e); + if (!complete) { + if (result != null) { + subscriber.onNext(result); + } + onError(e); + } + } + + private void maybeComplete() { + if (state.compareAndSet(SubscriberState.OUTBOUND_DONE, SubscriberState.COMPLETE)) { + channel.removeConfirmListener(confirmListener); + channel.removeShutdownListener(shutdownListener); + if (returnListener != null) { + channel.removeReturnListener(returnListener); + } + subscriber.onComplete(); + } + } + + boolean checkComplete(T t) { + boolean complete = state.get() == SubscriberState.COMPLETE; + if (complete && firstException.get() == null) { + Operators.onNextDropped(t, currentContext()); + } + return complete; + } + } + +} diff --git a/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/SenderOptions.java b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/SenderOptions.java new file mode 100644 index 00000000..dda4ce14 --- /dev/null +++ b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/SenderOptions.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2017-2021 VMware Inc. or its affiliates, All Rights Reserved. + * + * 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 reactor.rabbitmq; + +import com.rabbitmq.client.Channel; +import com.rabbitmq.client.Connection; +import com.rabbitmq.client.ConnectionFactory; +import lombok.Getter; +import reactor.core.publisher.Mono; +import reactor.core.publisher.SignalType; +import reactor.core.scheduler.Scheduler; + +import java.time.Duration; +import java.util.function.BiConsumer; +import java.util.function.UnaryOperator; + +@Getter +public class SenderOptions { + + private ConnectionFactory connectionFactory = new ConnectionFactory(); + + private Mono connectionMono; + private Mono channelMono; + private BiConsumer channelCloseHandler; + private Scheduler resourceManagementScheduler; + private Scheduler connectionSubscriptionScheduler; + private Mono resourceManagementChannelMono; + private Utils.ExceptionFunction connectionSupplier; + private UnaryOperator> connectionMonoConfigurator = cm -> cm; + private Duration connectionClosingTimeout = Duration.ofSeconds(30); + + public SenderOptions connectionFactory(ConnectionFactory connectionFactory) { + this.connectionFactory = connectionFactory; + return this; + } + + public SenderOptions resourceManagementScheduler(Scheduler resourceManagementScheduler) { + this.resourceManagementScheduler = resourceManagementScheduler; + return this; + } + + public SenderOptions connectionSubscriptionScheduler(Scheduler connectionSubscriptionScheduler) { + this.connectionSubscriptionScheduler = connectionSubscriptionScheduler; + return this; + } + + public SenderOptions connectionSupplier(Utils.ExceptionFunction function) { + return this.connectionSupplier(this.connectionFactory, function); + } + + public SenderOptions connectionSupplier(ConnectionFactory connectionFactory, + Utils.ExceptionFunction function) { + this.connectionSupplier = ignored -> function.apply(connectionFactory); + return this; + } + + public SenderOptions connectionMono(Mono connectionMono) { + this.connectionMono = connectionMono; + return this; + } + + public SenderOptions channelMono(Mono channelMono) { + this.channelMono = channelMono; + return this; + } + + public SenderOptions channelCloseHandler(BiConsumer channelCloseHandler) { + this.channelCloseHandler = channelCloseHandler; + return this; + } + + public SenderOptions channelPool(ChannelPool channelPool) { + this.channelMono = channelPool.getChannelMono(); + this.channelCloseHandler = channelPool.getChannelCloseHandler(); + return this; + } + + public SenderOptions connectionMonoConfigurator( + UnaryOperator> connectionMonoConfigurator) { + this.connectionMonoConfigurator = connectionMonoConfigurator; + return this; + } + + public SenderOptions resourceManagementChannelMono(Mono resourceManagementChannelMono) { + this.resourceManagementChannelMono = resourceManagementChannelMono; + return this; + } + + public SenderOptions connectionClosingTimeout(Duration connectionClosingTimeout) { + this.connectionClosingTimeout = connectionClosingTimeout; + return this; + } + +} diff --git a/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/SubscriberState.java b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/SubscriberState.java new file mode 100644 index 00000000..4dc64096 --- /dev/null +++ b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/SubscriberState.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2018-2021 VMware Inc. or its affiliates, All Rights Reserved. + * + * 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 reactor.rabbitmq; + +enum SubscriberState { + INIT, + ACTIVE, + OUTBOUND_DONE, + COMPLETE +} diff --git a/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/Utils.java b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/Utils.java new file mode 100644 index 00000000..9e9ef7e6 --- /dev/null +++ b/async/reactor-rabbitmq/src/main/java/reactor/rabbitmq/Utils.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2018-2021 VMware Inc. or its affiliates, All Rights Reserved. + * + * 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 reactor.rabbitmq; + +import com.rabbitmq.client.Channel; +import com.rabbitmq.client.Connection; +import com.rabbitmq.client.RecoverableChannel; +import com.rabbitmq.client.RecoverableConnection; +import reactor.core.publisher.Mono; + +import java.time.Duration; + +public abstract class Utils { + + public static Mono cache(Mono mono) { + return mono.cache( + element -> Duration.ofMillis(Long.MAX_VALUE), + throwable -> Duration.ZERO, + () -> Duration.ZERO); + } + + static boolean isRecoverable(Connection connection) { + return connection instanceof RecoverableConnection; + } + + static boolean isRecoverable(Channel channel) { + return channel instanceof RecoverableChannel; + } + + @FunctionalInterface + public interface ExceptionFunction { + R apply(T t) throws Exception; + } +} diff --git a/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/AcknowledgableDeliveryTest.java b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/AcknowledgableDeliveryTest.java new file mode 100644 index 00000000..7224d412 --- /dev/null +++ b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/AcknowledgableDeliveryTest.java @@ -0,0 +1,131 @@ +package reactor.rabbitmq; + +import com.rabbitmq.client.AMQP; +import com.rabbitmq.client.Channel; +import com.rabbitmq.client.Delivery; +import com.rabbitmq.client.Envelope; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BiConsumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +class AcknowledgableDeliveryTest { + + Channel channel; + Envelope envelope; + AMQP.BasicProperties props; + BiConsumer noopHandler; + + @BeforeEach + void setUp() { + channel = mock(Channel.class); + envelope = new Envelope(42L, false, "exch", "rk"); + props = new AMQP.BasicProperties(); + noopHandler = (ctx, ex) -> {}; + } + + @Test + void ackCallsBasicAck() throws Exception { + var delivery = new AcknowledgableDelivery( + new Delivery(envelope, props, "body".getBytes()), channel, noopHandler); + delivery.ack(); + verify(channel).basicAck(42L, false); + } + + @Test + void ackWithMultiple() throws Exception { + var delivery = new AcknowledgableDelivery( + new Delivery(envelope, props, "body".getBytes()), channel, noopHandler); + delivery.ack(true); + verify(channel).basicAck(42L, true); + } + + @Test + void ackIsIdempotent() throws Exception { + var delivery = new AcknowledgableDelivery( + new Delivery(envelope, props, "body".getBytes()), channel, noopHandler); + delivery.ack(); + delivery.ack(); + verify(channel, times(1)).basicAck(42L, false); + } + + @Test + void nackCallsBasicNack() throws Exception { + var delivery = new AcknowledgableDelivery( + new Delivery(envelope, props, "body".getBytes()), channel, noopHandler); + delivery.nack(true); + verify(channel).basicNack(42L, false, true); + } + + @Test + void nackWithMultipleAndRequeue() throws Exception { + var delivery = new AcknowledgableDelivery( + new Delivery(envelope, props, "body".getBytes()), channel, noopHandler); + delivery.nack(true, false); + verify(channel).basicNack(42L, true, false); + } + + @Test + void nackIsIdempotent() throws Exception { + var delivery = new AcknowledgableDelivery( + new Delivery(envelope, props, "body".getBytes()), channel, noopHandler); + delivery.nack(false); + delivery.nack(false); + verify(channel, times(1)).basicNack(42L, false, false); + } + + @Test + void ackThenNackIgnoresNack() throws Exception { + var delivery = new AcknowledgableDelivery( + new Delivery(envelope, props, "body".getBytes()), channel, noopHandler); + delivery.ack(); + delivery.nack(true); + verify(channel).basicAck(42L, false); + verify(channel, never()).basicNack(anyLong(), anyBoolean(), anyBoolean()); + } + + @Test + void ackFailureInvokesExceptionHandler() throws Exception { + doThrow(new IOException("channel closed")).when(channel).basicAck(anyLong(), anyBoolean()); + var handlerCalled = new AtomicBoolean(false); + BiConsumer handler = (ctx, ex) + -> handlerCalled.set(true); + + var delivery = new AcknowledgableDelivery( + new Delivery(envelope, props, "body".getBytes()), channel, handler); + delivery.ack(); + assertThat(handlerCalled).isTrue(); + } + + @Test + void nackFailureInvokesExceptionHandler() throws Exception { + doThrow(new IOException("channel closed")).when(channel).basicNack(anyLong(), anyBoolean(), anyBoolean()); + var handlerCalled = new AtomicBoolean(false); + BiConsumer handler = (ctx, ex) + -> handlerCalled.set(true); + + var delivery = new AcknowledgableDelivery( + new Delivery(envelope, props, "body".getBytes()), channel, handler); + delivery.nack(false); + assertThat(handlerCalled).isTrue(); + } + + @Test + void exceptionHandlerRethrowResetsAckFlag() throws Exception { + doThrow(new IOException("channel closed")).when(channel).basicAck(anyLong(), anyBoolean()); + BiConsumer handler = (ctx, ex) -> { + throw new RabbitFluxException(ex); + }; + + var delivery = new AcknowledgableDelivery( + new Delivery(envelope, props, "body".getBytes()), channel, handler); + assertThatThrownBy(delivery::ack).isInstanceOf(RabbitFluxException.class); + // After the handler rethrew, flag should be reset so ack can be retried + } +} diff --git a/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/BindingSpecificationTest.java b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/BindingSpecificationTest.java new file mode 100644 index 00000000..ca9ea092 --- /dev/null +++ b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/BindingSpecificationTest.java @@ -0,0 +1,68 @@ +package reactor.rabbitmq; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class BindingSpecificationTest { + + @Test + void defaultValues() { + var spec = BindingSpecification.binding(); + assertThat(spec.getQueue()).isNull(); + assertThat(spec.getExchange()).isNull(); + assertThat(spec.getExchangeTo()).isNull(); + assertThat(spec.getRoutingKey()).isNull(); + assertThat(spec.getArguments()).isNull(); + } + + @Test + void queueBindingFactory() { + var spec = BindingSpecification.binding("my-exchange", "my-rk", "my-queue"); + assertThat(spec.getExchange()).isEqualTo("my-exchange"); + assertThat(spec.getRoutingKey()).isEqualTo("my-rk"); + assertThat(spec.getQueue()).isEqualTo("my-queue"); + } + + @Test + void queueBindingStaticAlias() { + var spec = BindingSpecification.queueBinding("exch", "rk", "q"); + assertThat(spec.getExchange()).isEqualTo("exch"); + assertThat(spec.getRoutingKey()).isEqualTo("rk"); + assertThat(spec.getQueue()).isEqualTo("q"); + } + + @Test + void exchangeBindingFactory() { + var spec = BindingSpecification.exchangeBinding("from-exch", "rk", "to-exch"); + assertThat(spec.getExchange()).isEqualTo("from-exch"); + assertThat(spec.getRoutingKey()).isEqualTo("rk"); + assertThat(spec.getExchangeTo()).isEqualTo("to-exch"); + assertThat(spec.getQueue()).isNull(); + } + + @Test + void fluentSetters() { + Map args = Map.of("x-match", "all"); + var spec = BindingSpecification.binding() + .queue("q") + .exchange("e") + .exchangeTo("e2") + .routingKey("rk") + .arguments(args); + + assertThat(spec.getQueue()).isEqualTo("q"); + assertThat(spec.getExchange()).isEqualTo("e"); + assertThat(spec.getExchangeTo()).isEqualTo("e2"); + assertThat(spec.getRoutingKey()).isEqualTo("rk"); + assertThat(spec.getArguments()).isEqualTo(args); + } + + @Test + void exchangeFromSetsExchange() { + var spec = BindingSpecification.binding().exchangeFrom("src-exch"); + assertThat(spec.getExchange()).isEqualTo("src-exch"); + } +} diff --git a/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/ChannelCloseHandlersTest.java b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/ChannelCloseHandlersTest.java new file mode 100644 index 00000000..05d42c10 --- /dev/null +++ b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/ChannelCloseHandlersTest.java @@ -0,0 +1,75 @@ +package reactor.rabbitmq; + +import com.rabbitmq.client.Channel; +import com.rabbitmq.client.Connection; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.SignalType; + +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + + +class ChannelCloseHandlersTest { + + @Test + void closesOpenChannelOnOpenConnection() throws Exception { + var channel = mock(Channel.class); + var connection = mock(Connection.class); + when(channel.isOpen()).thenReturn(true); + when(channel.getConnection()).thenReturn(connection); + when(connection.isOpen()).thenReturn(true); + when(channel.getChannelNumber()).thenReturn(42); + + ChannelCloseHandlers.SENDER_CHANNEL_CLOSE_HANDLER_INSTANCE + .accept(SignalType.ON_COMPLETE, channel); + + verify(channel).close(); + } + + @Test + void doesNotCloseChannelWhenChannelAlreadyClosed() throws Exception { + var channel = mock(Channel.class); + when(channel.isOpen()).thenReturn(false); + when(channel.getChannelNumber()).thenReturn(1); + + ChannelCloseHandlers.SENDER_CHANNEL_CLOSE_HANDLER_INSTANCE + .accept(SignalType.ON_COMPLETE, channel); + + verify(channel, never()).close(); + } + + @Test + void doesNotCloseChannelWhenConnectionClosed() throws Exception { + var channel = mock(Channel.class); + var connection = mock(Connection.class); + when(channel.isOpen()).thenReturn(true); + when(channel.getConnection()).thenReturn(connection); + when(connection.isOpen()).thenReturn(false); + when(channel.getChannelNumber()).thenReturn(2); + + ChannelCloseHandlers.SENDER_CHANNEL_CLOSE_HANDLER_INSTANCE + .accept(SignalType.ON_COMPLETE, channel); + + verify(channel, never()).close(); + } + + @Test + void handlesCloseExceptionGracefully() throws Exception { + var channel = mock(Channel.class); + var connection = mock(Connection.class); + when(channel.isOpen()).thenReturn(true); + when(channel.getConnection()).thenReturn(connection); + when(connection.isOpen()).thenReturn(true); + when(channel.getChannelNumber()).thenReturn(3); + doThrow(new RuntimeException("close failed")).when(channel).close(); + + // Should not throw + Assertions.assertDoesNotThrow(() -> ChannelCloseHandlers.SENDER_CHANNEL_CLOSE_HANDLER_INSTANCE + .accept(SignalType.ON_ERROR, channel) + ); + } +} diff --git a/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/ChannelPoolFactoryTest.java b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/ChannelPoolFactoryTest.java new file mode 100644 index 00000000..e311286f --- /dev/null +++ b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/ChannelPoolFactoryTest.java @@ -0,0 +1,26 @@ +package reactor.rabbitmq; + +import com.rabbitmq.client.Connection; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class ChannelPoolFactoryTest { + + @Test + void createChannelPoolWithDefaultOptions() { + var connection = mock(Connection.class); + var pool = ChannelPoolFactory.createChannelPool(Mono.just(connection)); + assertThat(pool).isInstanceOf(LazyChannelPool.class); + } + + @Test + void createChannelPoolWithCustomOptions() { + var connection = mock(Connection.class); + var options = new ChannelPoolOptions().maxCacheSize(10); + var pool = ChannelPoolFactory.createChannelPool(Mono.just(connection), options); + assertThat(pool).isInstanceOf(LazyChannelPool.class); + } +} diff --git a/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/ChannelPoolOptionsTest.java b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/ChannelPoolOptionsTest.java new file mode 100644 index 00000000..173ecfe2 --- /dev/null +++ b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/ChannelPoolOptionsTest.java @@ -0,0 +1,29 @@ +package reactor.rabbitmq; + +import org.junit.jupiter.api.Test; +import reactor.core.scheduler.Schedulers; + +import static org.assertj.core.api.Assertions.assertThat; + +class ChannelPoolOptionsTest { + + @Test + void defaultValues() { + var opts = new ChannelPoolOptions(); + assertThat(opts.getMaxCacheSize()).isNull(); + assertThat(opts.getSubscriptionScheduler()).isNull(); + } + + @Test + void maxCacheSize() { + var opts = new ChannelPoolOptions().maxCacheSize(10); + assertThat(opts.getMaxCacheSize()).isEqualTo(10); + } + + @Test + void subscriptionScheduler() { + var scheduler = Schedulers.boundedElastic(); + var opts = new ChannelPoolOptions().subscriptionScheduler(scheduler); + assertThat(opts.getSubscriptionScheduler()).isSameAs(scheduler); + } +} diff --git a/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/ChannelProxyTest.java b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/ChannelProxyTest.java new file mode 100644 index 00000000..167babf4 --- /dev/null +++ b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/ChannelProxyTest.java @@ -0,0 +1,79 @@ +package reactor.rabbitmq; + +import com.rabbitmq.client.Channel; +import com.rabbitmq.client.Connection; +import com.rabbitmq.client.Method; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CompletableFuture; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class ChannelProxyTest { + + @Test + void asyncCompletableRpcDelegatesToRealChannel() throws Exception { + var connection = mock(Connection.class); + var realChannel = mock(Channel.class); + when(connection.createChannel()).thenReturn(realChannel); + when(realChannel.isOpen()).thenReturn(true); + + var command = mock(com.rabbitmq.client.Command.class); + var future = CompletableFuture.completedFuture(command); + when(realChannel.asyncCompletableRpc(any(Method.class))).thenReturn(future); + + Channel proxy = ChannelProxy.create(connection); + var method = mock(Method.class); + var result = proxy.asyncCompletableRpc(method); + + assertThat(result.get()).isSameAs(command); + verify(realChannel).asyncCompletableRpc(method); + } + + @Test + void asyncCompletableRpcRecreatesChannelWhenClosed() throws Exception { + var connection = mock(Connection.class); + var closedChannel = mock(Channel.class); + var newChannel = mock(Channel.class); + when(connection.createChannel()).thenReturn(closedChannel, newChannel); + when(closedChannel.isOpen()).thenReturn(false); + when(newChannel.isOpen()).thenReturn(true); + + var command = mock(com.rabbitmq.client.Command.class); + var future = CompletableFuture.completedFuture(command); + when(newChannel.asyncCompletableRpc(any(Method.class))).thenReturn(future); + + Channel proxy = ChannelProxy.create(connection); + var method = mock(Method.class); + proxy.asyncCompletableRpc(method); + + // closedChannel was detected as not open, newChannel was created + verify(connection, times(2)).createChannel(); + verify(newChannel).asyncCompletableRpc(method); + } + + @Test + void toStringReturnsReadableRepresentation() throws Exception { + var connection = mock(Connection.class); + var realChannel = mock(Channel.class); + when(connection.createChannel()).thenReturn(realChannel); + + Channel proxy = ChannelProxy.create(connection); + assertThat(proxy.toString()).startsWith("ChannelProxy[delegate="); + } + + @Test + void unsupportedMethodThrowsException() throws Exception { + var connection = mock(Connection.class); + var realChannel = mock(Channel.class); + when(connection.createChannel()).thenReturn(realChannel); + + Channel proxy = ChannelProxy.create(connection); + assertThatThrownBy(() -> proxy.basicPublish("", "", null, new byte[0])) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("basicPublish"); + } +} diff --git a/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/ConsumeOptionsTest.java b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/ConsumeOptionsTest.java new file mode 100644 index 00000000..e9e9541d --- /dev/null +++ b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/ConsumeOptionsTest.java @@ -0,0 +1,72 @@ +package reactor.rabbitmq; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.FluxSink; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ConsumeOptionsTest { + + @Test + void defaultValues() { + var opts = new ConsumeOptions(); + assertThat(opts.getQos()).isEqualTo(250); + assertThat(opts.getConsumerTag()).isEmpty(); + assertThat(opts.getOverflowStrategy()).isEqualTo(FluxSink.OverflowStrategy.BUFFER); + assertThat(opts.getHookBeforeEmitBiFunction()).isNotNull(); + assertThat(opts.getStopConsumingBiFunction()).isNotNull(); + assertThat(opts.getExceptionHandler()).isNotNull(); + assertThat(opts.getChannelCallback()).isNotNull(); + assertThat(opts.getArguments()).isEmpty(); + } + + @Test + void hookBeforeEmitDefaultReturnsTrue() { + var opts = new ConsumeOptions(); + assertThat(opts.getHookBeforeEmitBiFunction().test(1L, null)).isTrue(); + } + + @Test + void stopConsumingDefaultReturnsFalse() { + var opts = new ConsumeOptions(); + assertThat(opts.getStopConsumingBiFunction().test(1L, null)).isFalse(); + } + + @Test + void qosRejectsNegative() { + var opts = new ConsumeOptions(); + assertThatThrownBy(() -> opts.qos(-1)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void qosAcceptsZero() { + var opts = new ConsumeOptions().qos(0); + assertThat(opts.getQos()).isZero(); + } + + @Test + void consumerTagRejectsNull() { + var opts = new ConsumeOptions(); + assertThatThrownBy(() -> opts.consumerTag(null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void fluentSetters() { + Map args = Map.of("key", "value"); + var opts = new ConsumeOptions() + .qos(100) + .consumerTag("tag") + .overflowStrategy(FluxSink.OverflowStrategy.DROP) + .arguments(args); + + assertThat(opts.getQos()).isEqualTo(100); + assertThat(opts.getConsumerTag()).isEqualTo("tag"); + assertThat(opts.getOverflowStrategy()).isEqualTo(FluxSink.OverflowStrategy.DROP); + assertThat(opts.getArguments()).isEqualTo(args); + } +} diff --git a/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/ExceptionHandlersTest.java b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/ExceptionHandlersTest.java new file mode 100644 index 00000000..f4b0e671 --- /dev/null +++ b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/ExceptionHandlersTest.java @@ -0,0 +1,130 @@ +package reactor.rabbitmq; + +import com.rabbitmq.client.ShutdownSignalException; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.Callable; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Predicate; + +import static java.time.Duration.ofMillis; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ExceptionHandlersTest { + + @Test + void connectionRecoveryTriggeringHardErrorNotByApplication() { + Predicate predicate = new ExceptionHandlers.ConnectionRecoveryTriggeringPredicate(); + assertTrue(predicate.test(new ShutdownSignalException(true, false, null, null)), + "hard error, not triggered by application"); + } + + @Test + void connectionRecoveryTriggeringSoftErrorNotByApplication() { + Predicate predicate = new ExceptionHandlers.ConnectionRecoveryTriggeringPredicate(); + assertTrue(predicate.test(new ShutdownSignalException(false, false, null, null)), + "soft error, not triggered by application"); + } + + @Test + void connectionRecoveryNotTriggeringWhenInitiatedByApplication() { + Predicate predicate = new ExceptionHandlers.ConnectionRecoveryTriggeringPredicate(); + assertFalse(predicate.test(new ShutdownSignalException(false, true, null, null)), + "soft error, triggered by application"); + } + + @Test + void connectionRecoveryFalseForNonShutdownSignal() { + Predicate predicate = new ExceptionHandlers.ConnectionRecoveryTriggeringPredicate(); + assertFalse(predicate.test(new RuntimeException("not a shutdown signal"))); + } + + @Test + void connectionRecoveryPredicateConstantShouldWork() { + assertNotNull(ExceptionHandlers.CONNECTION_RECOVERY_PREDICATE); + assertFalse(ExceptionHandlers.CONNECTION_RECOVERY_PREDICATE.test(new RuntimeException())); + } + + @Test + void simpleRetryTemplateShouldThrowIfNotRetryable() { + var retryTemplate = new ExceptionHandlers.SimpleRetryTemplate( + ofMillis(100), ofMillis(10), ExceptionHandlers.CONNECTION_RECOVERY_PREDICATE); + var illegalArgEx = new IllegalArgumentException(); + assertThatThrownBy(() -> retryTemplate.retry(() -> null, illegalArgEx)) + .isInstanceOf(RabbitFluxException.class); + } + + @Test + void simpleRetryTemplateInvalidArguments() { + var timeout100 = ofMillis(100); + var timeout10 = ofMillis(10); + assertThatThrownBy(() -> new ExceptionHandlers.SimpleRetryTemplate(null, timeout10, e -> true)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new ExceptionHandlers.SimpleRetryTemplate(timeout100, null, e -> true)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new ExceptionHandlers.SimpleRetryTemplate(timeout10, timeout100, e -> true)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new ExceptionHandlers.SimpleRetryTemplate(timeout100, timeout10, null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void retrySucceeds() { + var handler = new ExceptionHandlers.RetrySendingExceptionHandler( + ofMillis(200), ofMillis(10), e -> true); + AtomicLong counter = new AtomicLong(0); + handler.accept(sendContext(() -> { + if (counter.incrementAndGet() < 3) { + throw new Exception(); + } + return null; + }), new Exception()); + assertEquals(3, counter.get()); + } + + @Test + void retryTimeoutIsReachedAndDoesNotThrowWhenNotConfigured() { + var handler = new ExceptionHandlers.RetrySendingExceptionHandler( + ofMillis(50), ofMillis(10), e -> true, false); + var ctx = sendContext(() -> { throw new Exception(); }); + assertThatNoException().isThrownBy(() -> handler.accept(ctx, new Exception())); + } + + @Test + void retryTimeoutThrowsWhenConfigured() { + var handler = new ExceptionHandlers.RetrySendingExceptionHandler( + ofMillis(50), ofMillis(10), e -> true, true); + var ctx = sendContext(() -> { throw new Exception(); }); + var triggerEx = new Exception(); + assertThatThrownBy(() -> handler.accept(ctx, triggerEx)) + .isInstanceOf(RabbitFluxRetryTimeoutException.class); + } + + @Test + void retryAcknowledgmentHandlerCallsAckOrNack() { + var handler = new ExceptionHandlers.RetryAcknowledgmentExceptionHandler( + ofMillis(200), ofMillis(10), e -> true); + AtomicLong counter = new AtomicLong(0); + var context = new Receiver.AcknowledgmentContext(null, delivery -> { + if (counter.incrementAndGet() < 2) { + throw new RuntimeException("transient"); + } + }); + handler.accept(context, new RuntimeException("initial")); + assertEquals(2, counter.get()); + } + + private Sender.SendContext sendContext(Callable callable) { + return new Sender.SendContext<>(null, null) { + @Override + public void publish() throws Exception { + callable.call(); + } + }; + } +} diff --git a/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/ExchangeSpecificationTest.java b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/ExchangeSpecificationTest.java new file mode 100644 index 00000000..302ab5d5 --- /dev/null +++ b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/ExchangeSpecificationTest.java @@ -0,0 +1,50 @@ +package reactor.rabbitmq; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class ExchangeSpecificationTest { + + @Test + void defaultValues() { + var spec = ExchangeSpecification.exchange(); + assertThat(spec.getName()).isNull(); + assertThat(spec.getType()).isEqualTo("direct"); + assertThat(spec.isDurable()).isFalse(); + assertThat(spec.isAutoDelete()).isFalse(); + assertThat(spec.isInternal()).isFalse(); + assertThat(spec.isPassive()).isFalse(); + assertThat(spec.getArguments()).isNull(); + } + + @Test + void factoryWithName() { + var spec = ExchangeSpecification.exchange("my-exchange"); + assertThat(spec.getName()).isEqualTo("my-exchange"); + assertThat(spec.getType()).isEqualTo("direct"); + } + + @Test + void fluentSetters() { + Map args = Map.of("x-expires", 60000); + var spec = ExchangeSpecification.exchange() + .name("test") + .type("fanout") + .durable(true) + .autoDelete(true) + .internal(true) + .passive(true) + .arguments(args); + + assertThat(spec.getName()).isEqualTo("test"); + assertThat(spec.getType()).isEqualTo("fanout"); + assertThat(spec.isDurable()).isTrue(); + assertThat(spec.isAutoDelete()).isTrue(); + assertThat(spec.isInternal()).isTrue(); + assertThat(spec.isPassive()).isTrue(); + assertThat(spec.getArguments()).isEqualTo(args); + } +} diff --git a/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/HelpersTest.java b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/HelpersTest.java new file mode 100644 index 00000000..19b44633 --- /dev/null +++ b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/HelpersTest.java @@ -0,0 +1,33 @@ +package reactor.rabbitmq; + +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; + +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +class HelpersTest { + + @Test + void safelyExecuteRunsAction() { + var logger = mock(Logger.class); + var ran = new AtomicBoolean(false); + Helpers.safelyExecute(logger, () -> ran.set(true), "test"); + assertThat(ran).isTrue(); + verify(logger, never()).warn(anyString(), anyString(), anyString()); + } + + @Test + void safelyExecuteLogsExceptionAndDoesNotThrow() { + var logger = mock(Logger.class); + Helpers.safelyExecute(logger, () -> { + throw new RuntimeException("boom"); + }, "action failed"); + verify(logger).warn("{}: {}", "action failed", "boom"); + } +} diff --git a/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/LazyChannelPoolTest.java b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/LazyChannelPoolTest.java new file mode 100644 index 00000000..3142991e --- /dev/null +++ b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/LazyChannelPoolTest.java @@ -0,0 +1,172 @@ +package reactor.rabbitmq; + +import com.rabbitmq.client.AMQP; +import com.rabbitmq.client.Channel; +import com.rabbitmq.client.Connection; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import reactor.test.scheduler.VirtualTimeScheduler; + +import java.io.IOException; +import java.time.Duration; + +import static java.time.Duration.ofSeconds; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.nullable; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class LazyChannelPoolTest { + + LazyChannelPool lazyChannelPool; + Connection connection; + Channel channel1, channel2, channel3; + + @BeforeEach + void setUp() throws IOException { + connection = mock(Connection.class); + when(connection.isOpen()).thenReturn(true); + channel1 = channel(1); + channel2 = channel(2); + channel3 = channel(3); + when(connection.createChannel()).thenReturn(channel1, channel2, channel3); + } + + @Test + void testChannelPoolLazyInitialization() throws Exception { + var options = new ChannelPoolOptions() + .maxCacheSize(2) + .subscriptionScheduler(VirtualTimeScheduler.create()); + lazyChannelPool = new LazyChannelPool(Mono.just(connection), options); + + StepVerifier.withVirtualTime(() -> + Mono.when( + useChannelFromStartUntil(ofSeconds(1)), + useChannelBetween(ofSeconds(2), ofSeconds(3)) + )) + .expectSubscription() + .thenAwait(ofSeconds(3)) + .verifyComplete(); + + // channel1 used twice (created at 0, pooled at 1, reused at 2) + verifyBasicPublish(channel1, 2); + verifyBasicPublishNever(channel2); + verify(channel1, never()).close(); + + lazyChannelPool.close(); + + verify(channel1).close(); + verify(channel2, never()).close(); + verify(channel1).clearConfirmListeners(); + verify(channel2, never()).clearConfirmListeners(); + } + + @Test + void testChannelPoolExceedsMaxPoolSize() throws Exception { + var options = new ChannelPoolOptions() + .maxCacheSize(2) + .subscriptionScheduler(VirtualTimeScheduler.create()); + lazyChannelPool = new LazyChannelPool(Mono.just(connection), options); + + StepVerifier.withVirtualTime(() -> + Mono.when( + useChannelBetween(ofSeconds(1), ofSeconds(4)), + useChannelBetween(ofSeconds(2), ofSeconds(5)), + useChannelBetween(ofSeconds(3), ofSeconds(6)) + )) + .expectSubscription() + .thenAwait(ofSeconds(6)) + .verifyComplete(); + + verifyBasicPublishOnce(channel1); + verifyBasicPublishOnce(channel2); + verifyBasicPublishOnce(channel3); + verify(channel1, never()).close(); + verify(channel2, never()).close(); + verify(channel3).close(); // exceeded pool capacity + + lazyChannelPool.close(); + + verify(channel1).close(); + verify(channel2).close(); + } + + @Test + void testChannelPool() throws Exception { + var options = new ChannelPoolOptions() + .maxCacheSize(1) + .subscriptionScheduler(VirtualTimeScheduler.create()); + lazyChannelPool = new LazyChannelPool(Mono.just(connection), options); + + StepVerifier.withVirtualTime(() -> + Mono.when( + useChannelFromStartUntil(ofSeconds(3)), + useChannelBetween(ofSeconds(1), ofSeconds(2)), + useChannelBetween(ofSeconds(4), ofSeconds(5)) + )) + .expectSubscription() + .thenAwait(ofSeconds(5)) + .verifyComplete(); + + verifyBasicPublishOnce(channel1); + verifyBasicPublish(channel2, 2); + verify(channel1).close(); // exceeded pool at second 3 + verify(channel2, never()).close(); + verify(channel1, never()).clearConfirmListeners(); + verify(channel2).clearConfirmListeners(); + + lazyChannelPool.close(); + verify(channel2).close(); + } + + private Mono useChannelFromStartUntil(Duration until) { + return useChannelBetween(Duration.ZERO, until); + } + + private Mono useChannelBetween(Duration from, Duration to) { + return Mono.delay(from) + .then(lazyChannelPool.getChannelMono()) + .flatMap(channel -> + Mono.just(1) + .doOnNext(i -> { + try { + channel.basicPublish("", "", null, "".getBytes()); + } catch (IOException e) { + throw new RuntimeException(e); + } + }) + .delayElement(to.minus(from)) + .doFinally(signalType -> + lazyChannelPool.getChannelCloseHandler().accept(signalType, channel) + ) + ) + .then(); + } + + private void verifyBasicPublishNever(Channel channel) throws Exception { + verifyBasicPublish(channel, 0); + } + + private void verifyBasicPublishOnce(Channel channel) throws Exception { + verifyBasicPublish(channel, 1); + } + + private void verifyBasicPublish(Channel channel, int times) throws Exception { + verify(channel, times(times)).basicPublish(anyString(), anyString(), + nullable(AMQP.BasicProperties.class), any(byte[].class)); + } + + private Channel channel(int channelNumber) { + Channel channel = mock(Channel.class); + when(channel.getChannelNumber()).thenReturn(channelNumber); + when(channel.isOpen()).thenReturn(true); + when(channel.getConnection()).thenReturn(connection); + return channel; + } +} diff --git a/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/OutboundMessageResultTest.java b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/OutboundMessageResultTest.java new file mode 100644 index 00000000..e3440b4b --- /dev/null +++ b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/OutboundMessageResultTest.java @@ -0,0 +1,34 @@ +package reactor.rabbitmq; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class OutboundMessageResultTest { + + @Test + void twoArgConstructorDefaultsReturnedToFalse() { + var msg = new OutboundMessage("exch", "rk", "body".getBytes()); + var result = new OutboundMessageResult<>(msg, true); + assertThat(result.outboundMessage()).isSameAs(msg); + assertThat(result.ack()).isTrue(); + assertThat(result.returned()).isFalse(); + } + + @Test + void threeArgConstructor() { + var msg = new OutboundMessage("exch", "rk", "body".getBytes()); + var result = new OutboundMessageResult<>(msg, false, true); + assertThat(result.outboundMessage()).isSameAs(msg); + assertThat(result.ack()).isFalse(); + assertThat(result.returned()).isTrue(); + } + + @Test + void toStringContainsAckAndReturned() { + var msg = new OutboundMessage("exch", "rk", "body".getBytes()); + var result = new OutboundMessageResult<>(msg, true, true); + var str = result.toString(); + assertThat(str).contains("ack=true").contains("returned=true"); + } +} diff --git a/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/OutboundMessageTest.java b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/OutboundMessageTest.java new file mode 100644 index 00000000..25737214 --- /dev/null +++ b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/OutboundMessageTest.java @@ -0,0 +1,90 @@ +package reactor.rabbitmq; + +import com.rabbitmq.client.AMQP; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class OutboundMessageTest { + + @Test + void constructorWithoutProperties() { + var msg = new OutboundMessage("exch", "rk", "body".getBytes()); + assertThat(msg.getExchange()).isEqualTo("exch"); + assertThat(msg.getRoutingKey()).isEqualTo("rk"); + assertThat(msg.getProperties()).isNull(); + assertThat(msg.getBody()).isEqualTo("body".getBytes()); + assertThat(msg.isPublished()).isFalse(); + } + + @Test + void constructorWithProperties() { + var props = new AMQP.BasicProperties.Builder().contentType("text/plain").build(); + var msg = new OutboundMessage("exch", "rk", props, "body".getBytes()); + assertThat(msg.getExchange()).isEqualTo("exch"); + assertThat(msg.getRoutingKey()).isEqualTo("rk"); + assertThat(msg.getProperties()).isSameAs(props); + assertThat(msg.getBody()).isEqualTo("body".getBytes()); + } + + @Test + void publishedFlagIsSetByPublished() { + var msg = new OutboundMessage("exch", "rk", new byte[0]); + assertThat(msg.isPublished()).isFalse(); + msg.published(); + assertThat(msg.isPublished()).isTrue(); + } + + @Test + void toStringContainsFieldValues() { + var msg = new OutboundMessage("exch", "rk", "hi".getBytes()); + var str = msg.toString(); + assertThat(str).contains("exch").contains("rk"); + } + + @Test + void shouldCreateMessageWithAckNotifier() { + AMQP.BasicProperties properties = new AMQP.BasicProperties(); + byte[] body = "test message".getBytes(); + AtomicBoolean ackCalled = new AtomicBoolean(false); + + OutboundMessage message = new OutboundMessage( + "test.exchange", "test.routingKey", properties, body, ackCalled::set + ); + + assertEquals("test.exchange", message.getExchange()); + assertEquals("test.routingKey", message.getRoutingKey()); + assertEquals(properties, message.getProperties()); + assertArrayEquals(body, message.getBody()); + assertNotNull(message.getAckNotifier()); + } + + @Test + void shouldInvokeAckNotifierWhenCalled() { + AtomicBoolean ackCalled = new AtomicBoolean(false); + + OutboundMessage message = new OutboundMessage( + "test.exchange", "test.routingKey", new AMQP.BasicProperties(), + "test message".getBytes(), ackCalled::set + ); + + message.getAckNotifier().accept(true); + + assertTrue(ackCalled.get()); + } + + @Test + void shouldCreateMessageWithoutAckNotifier() { + OutboundMessage message = new OutboundMessage( + "test.exchange", "test.routingKey", new AMQP.BasicProperties(), "body".getBytes() + ); + assertNull(message.getAckNotifier()); + } +} diff --git a/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/QueueSpecificationTest.java b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/QueueSpecificationTest.java new file mode 100644 index 00000000..b4448ed5 --- /dev/null +++ b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/QueueSpecificationTest.java @@ -0,0 +1,143 @@ +package reactor.rabbitmq; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.*; + +class QueueSpecificationTest { + + @Nested + class NullName { + @Test + void nullNameShouldReturnANonDurableQueue() { + assertFalse(QueueSpecification.queue().isDurable()); + } + + @Test + void passingNullNameShouldReturnANonDurableQueue() { + assertFalse(QueueSpecification.queue(null).isDurable()); + } + + @Test + void nullNameShouldNotAbleToConfigureDurableToTrue() { + var spec = QueueSpecification.queue(); + assertThatThrownBy(() -> spec.durable(true)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void nullNameShouldKeepDurableWhenConfigureDurableToFalse() { + assertFalse(QueueSpecification.queue().durable(false).isDurable()); + } + + @Test + void passingNullNameShouldReturnAnExclusiveQueue() { + assertTrue(QueueSpecification.queue().isExclusive()); + } + + @Test + void nullNameShouldNotAbleToConfigureExclusiveToFalse() { + var spec = QueueSpecification.queue(); + assertThatThrownBy(() -> spec.exclusive(false)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void nullNameShouldKeepExclusiveWhenConfigureExclusiveToTrue() { + assertTrue(QueueSpecification.queue().exclusive(true).isExclusive()); + } + + @Test + void passingNullNameShouldReturnAnAutoDeleteQueue() { + assertTrue(QueueSpecification.queue().isAutoDelete()); + } + + @Test + void nullNameShouldNotAbleToConfigureAutoDeleteToFalse() { + var spec = QueueSpecification.queue(); + assertThatThrownBy(() -> spec.autoDelete(false)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void nullNameShouldKeepAutoDeleteWhenConfigureAutoDeleteToTrue() { + assertTrue(QueueSpecification.queue().autoDelete(true).isAutoDelete()); + } + + @Test + void nullNameShouldNotAbleToConfigurePassiveToTrue() { + var spec = QueueSpecification.queue(); + assertThatThrownBy(() -> spec.passive(true)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void passingANonNullNameAfterShouldReturnAConfigurableQueueSpecification() { + var spec = QueueSpecification.queue() + .name("not-null-anymore") + .durable(true) + .exclusive(false) + .autoDelete(false) + .passive(false); + + assertEquals("not-null-anymore", spec.getName()); + assertTrue(spec.isDurable()); + assertFalse(spec.isAutoDelete()); + assertFalse(spec.isExclusive()); + assertFalse(spec.isPassive()); + } + } + + @Nested + class NotNullName { + @Test + void queueSpecificationShouldReturnCorrespondingProperties() { + var spec = QueueSpecification.queue("my-queue") + .durable(false) + .autoDelete(false) + .exclusive(false) + .passive(true); + + assertEquals("my-queue", spec.getName()); + assertFalse(spec.isDurable()); + assertFalse(spec.isAutoDelete()); + assertFalse(spec.isExclusive()); + assertTrue(spec.isPassive()); + assertNull(spec.getArguments()); + } + + @Test + void queueSpecificationShouldReturnCorrespondingPropertiesWhenEmptyName() { + var spec = QueueSpecification.queue("") + .durable(false) + .autoDelete(false) + .exclusive(false) + .passive(false); + + assertEquals("", spec.getName()); + assertFalse(spec.isDurable()); + assertFalse(spec.isAutoDelete()); + assertFalse(spec.isExclusive()); + assertFalse(spec.isPassive()); + assertNull(spec.getArguments()); + } + + @Test + void queueSpecificationShouldKeepArgumentsWhenNullName() { + var spec = QueueSpecification.queue("") + .arguments(Collections.singletonMap("x-max-length", 1000)) + .name(null); + assertNull(spec.getName()); + assertFalse(spec.isDurable()); + assertTrue(spec.isAutoDelete()); + assertTrue(spec.isExclusive()); + assertFalse(spec.isPassive()); + assertThat(spec.getArguments()).isNotNull().hasSize(1).containsKey("x-max-length"); + } + } +} diff --git a/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/RabbitFluxExceptionTest.java b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/RabbitFluxExceptionTest.java new file mode 100644 index 00000000..b13586a0 --- /dev/null +++ b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/RabbitFluxExceptionTest.java @@ -0,0 +1,45 @@ +package reactor.rabbitmq; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class RabbitFluxExceptionTest { + + @Test + void noArgConstructor() { + var ex = new RabbitFluxException(); + assertThat(ex.getMessage()).isNull(); + assertThat(ex.getCause()).isNull(); + } + + @Test + void messageConstructor() { + var ex = new RabbitFluxException("error"); + assertThat(ex.getMessage()).isEqualTo("error"); + } + + @Test + void messageCauseConstructor() { + var cause = new RuntimeException("root"); + var ex = new RabbitFluxException("error", cause); + assertThat(ex.getMessage()).isEqualTo("error"); + assertThat(ex.getCause()).isSameAs(cause); + } + + @Test + void causeConstructor() { + var cause = new RuntimeException("root"); + var ex = new RabbitFluxException(cause); + assertThat(ex.getCause()).isSameAs(cause); + } + + @Test + void retryTimeoutExceptionExtends() { + var cause = new RuntimeException("timeout"); + var ex = new RabbitFluxRetryTimeoutException("timed out", cause); + assertThat(ex).isInstanceOf(RabbitFluxException.class); + assertThat(ex.getMessage()).isEqualTo("timed out"); + assertThat(ex.getCause()).isSameAs(cause); + } +} diff --git a/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/ReceiverOptionsTest.java b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/ReceiverOptionsTest.java new file mode 100644 index 00000000..0e758063 --- /dev/null +++ b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/ReceiverOptionsTest.java @@ -0,0 +1,84 @@ +package reactor.rabbitmq; + +import com.rabbitmq.client.Connection; +import com.rabbitmq.client.ConnectionFactory; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +import java.time.Duration; +import java.util.function.UnaryOperator; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class ReceiverOptionsTest { + + @Test + void defaultValues() { + var opts = new ReceiverOptions(); + assertThat(opts.getConnectionFactory()).isNotNull(); + assertThat(opts.getConnectionMono()).isNull(); + assertThat(opts.getConnectionSubscriptionScheduler()).isNull(); + assertThat(opts.getConnectionSupplier()).isNull(); + assertThat(opts.getConnectionMonoConfigurator()).isNotNull(); + assertThat(opts.getConnectionClosingTimeout()).isEqualTo(Duration.ofSeconds(30)); + } + + @Test + void connectionFactory() { + var factory = new ConnectionFactory(); + var opts = new ReceiverOptions().connectionFactory(factory); + assertThat(opts.getConnectionFactory()).isSameAs(factory); + } + + @Test + void connectionMono() { + Mono mono = Mono.just(mock(Connection.class)); + var opts = new ReceiverOptions().connectionMono(mono); + assertThat(opts.getConnectionMono()).isSameAs(mono); + } + + @Test + void connectionSubscriptionScheduler() { + var scheduler = Schedulers.boundedElastic(); + var opts = new ReceiverOptions().connectionSubscriptionScheduler(scheduler); + assertThat(opts.getConnectionSubscriptionScheduler()).isSameAs(scheduler); + } + + @Test + void connectionSupplier() throws Exception { + var connection = mock(Connection.class); + var opts = new ReceiverOptions().connectionSupplier(cf -> connection); + assertThat(opts.getConnectionSupplier()).isNotNull(); + assertThat(opts.getConnectionSupplier().apply(null)).isSameAs(connection); + } + + @Test + void connectionSupplierWithExplicitFactory() throws Exception { + var connection = mock(Connection.class); + var factory = new ConnectionFactory(); + var opts = new ReceiverOptions().connectionSupplier(factory, cf -> connection); + assertThat(opts.getConnectionSupplier().apply(null)).isSameAs(connection); + } + + @Test + void connectionMonoConfigurator() { + UnaryOperator> configurator = Mono::cache; + var opts = new ReceiverOptions().connectionMonoConfigurator(configurator); + assertThat(opts.getConnectionMonoConfigurator()).isSameAs(configurator); + } + + @Test + void connectionMonoConfiguratorDefaultIsIdentity() { + var opts = new ReceiverOptions(); + Mono mono = Mono.just(mock(Connection.class)); + assertThat(opts.getConnectionMonoConfigurator().apply(mono)).isSameAs(mono); + } + + @Test + void connectionClosingTimeout() { + var opts = new ReceiverOptions().connectionClosingTimeout(Duration.ofMillis(500)); + assertThat(opts.getConnectionClosingTimeout()).isEqualTo(Duration.ofMillis(500)); + } +} diff --git a/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/ReceiverTest.java b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/ReceiverTest.java new file mode 100644 index 00000000..a46cde44 --- /dev/null +++ b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/ReceiverTest.java @@ -0,0 +1,191 @@ +package reactor.rabbitmq; + +import com.rabbitmq.client.Channel; +import com.rabbitmq.client.Connection; +import com.rabbitmq.client.DeliverCallback; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import java.io.IOException; +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyMap; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ReceiverTest { + + Connection connection; + Channel channel; + Receiver receiver; + + @BeforeEach + void setUp() throws Exception { + connection = mock(Connection.class); + channel = mock(Channel.class); + when(connection.createChannel()).thenReturn(channel); + when(channel.isOpen()).thenReturn(true); + when(channel.getConnection()).thenReturn(connection); + } + + @AfterEach + void tearDown() { + if (receiver != null) { + receiver.close(); + } + } + + @Test + void consumeManualAckRegistersConsumer() throws Exception { + AtomicReference deliverRef = new AtomicReference<>(); + when(channel.basicConsume(anyString(), eq(false), anyString(), eq(false), eq(false), + anyMap(), any(DeliverCallback.class), any(com.rabbitmq.client.CancelCallback.class))) + .thenAnswer(invocation -> { + deliverRef.set(invocation.getArgument(6)); + return "consumer-tag-1"; + }); + + receiver = new Receiver(new ReceiverOptions().connectionMono(Mono.just(connection))); + + var latch = new CountDownLatch(1); + receiver.consumeManualAck("test-queue") + .subscribe(delivery -> { + delivery.ack(); + latch.countDown(); + }); + + // Simulate delivery + if (deliverRef.get() != null) { + var envelope = new com.rabbitmq.client.Envelope(1L, false, "exch", "rk"); + var props = new com.rabbitmq.client.AMQP.BasicProperties(); + deliverRef.get().handle("consumer-tag-1", + new com.rabbitmq.client.Delivery(envelope, props, "hello".getBytes())); + } + + assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); + verify(channel).basicAck(1L, false); + } + + @Test + void consumeAutoAckAutoAcksMessages() throws Exception { + AtomicReference deliverRef = new AtomicReference<>(); + when(channel.basicConsume(anyString(), eq(false), anyString(), eq(false), eq(false), + anyMap(), any(DeliverCallback.class), any(com.rabbitmq.client.CancelCallback.class))) + .thenAnswer(invocation -> { + deliverRef.set(invocation.getArgument(6)); + return "consumer-tag-2"; + }); + + receiver = new Receiver(new ReceiverOptions().connectionMono(Mono.just(connection))); + + var latch = new CountDownLatch(1); + receiver.consumeAutoAck("test-queue") + .subscribe(delivery -> latch.countDown()); + + if (deliverRef.get() != null) { + var envelope = new com.rabbitmq.client.Envelope(1L, false, "exch", "rk"); + var props = new com.rabbitmq.client.AMQP.BasicProperties(); + deliverRef.get().handle("consumer-tag-2", + new com.rabbitmq.client.Delivery(envelope, props, "hello".getBytes())); + } + + assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); + verify(channel).basicAck(1L, false); + } + + @Test + void consumeManualAckEmitsErrorOnChannelCreationFailure() throws Exception { + when(connection.createChannel()).thenThrow(new IOException("channel creation failed")); + receiver = new Receiver(new ReceiverOptions().connectionMono(Mono.just(connection))); + var publisher = receiver.consumeManualAck("test-queue"); + var timeout = Duration.ofSeconds(5); + assertThatThrownBy(() -> publisher.blockLast(timeout)) + .isInstanceOf(RabbitFluxException.class); + } + + @Test + void closeIsIdempotent() { + receiver = new Receiver(new ReceiverOptions().connectionMono(Mono.just(connection))); + assertThatNoException().isThrownBy(() -> { + receiver.close(); + receiver.close(); + }); + receiver = null; // prevent double close in tearDown + } + + @Test + void connectionClosedWithTimeout() throws Exception { + Connection c = mock(Connection.class); + Channel ch = mock(Channel.class); + when(c.createChannel()).thenReturn(ch); + when(ch.isOpen()).thenReturn(true); + when(ch.getConnection()).thenReturn(c); + var connectionLatch = new CountDownLatch(1); + when(ch.basicConsume(anyString(), eq(false), anyString(), eq(false), eq(false), + anyMap(), any(com.rabbitmq.client.DeliverCallback.class), any(com.rabbitmq.client.CancelCallback.class) + )) + .thenAnswer(inv -> { + connectionLatch.countDown(); + return "tag"; + }); + + var options = new ReceiverOptions() + .connectionSupplier(cf -> c) + .connectionClosingTimeout(Duration.ofMillis(5000)); + + receiver = new Receiver(options); + // Trigger connection establishment by subscribing + receiver.consumeManualAck("test-queue").subscribe().dispose(); + assertThat(connectionLatch.await(5, TimeUnit.SECONDS)).isTrue(); + receiver.close(); + + verify(c, times(1)).close(5000); + receiver = null; + } + + @Test + void channelCallbackIsCalled() throws Exception { + AtomicReference deliverRef = new AtomicReference<>(); + when(channel.basicConsume(anyString(), eq(false), anyString(), eq(false), eq(false), + anyMap(), any(DeliverCallback.class), any(com.rabbitmq.client.CancelCallback.class))) + .thenAnswer(invocation -> { + deliverRef.set(invocation.getArgument(6)); + return "consumer-tag-3"; + }); + + receiver = new Receiver(new ReceiverOptions().connectionMono(Mono.just(connection))); + + var callbackCalled = new CountDownLatch(1); + var consumeOptions = new ConsumeOptions().channelCallback(ch -> callbackCalled.countDown()); + + var latch = new CountDownLatch(1); + receiver.consumeManualAck("test-queue", consumeOptions) + .subscribe(delivery -> { + delivery.ack(); + latch.countDown(); + }); + + if (deliverRef.get() != null) { + var envelope = new com.rabbitmq.client.Envelope(1L, false, "exch", "rk"); + var props = new com.rabbitmq.client.AMQP.BasicProperties(); + deliverRef.get().handle("consumer-tag-3", + new com.rabbitmq.client.Delivery(envelope, props, "hello".getBytes())); + } + + assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(callbackCalled.await(5, TimeUnit.SECONDS)).isTrue(); + } +} diff --git a/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/SendOptionsTest.java b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/SendOptionsTest.java new file mode 100644 index 00000000..295ef6af --- /dev/null +++ b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/SendOptionsTest.java @@ -0,0 +1,73 @@ +package reactor.rabbitmq; + +import com.rabbitmq.client.Channel; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.core.publisher.SignalType; +import reactor.core.scheduler.Schedulers; + +import java.util.function.BiConsumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +class SendOptionsTest { + + @Test + void defaultValues() { + var opts = new SendOptions(); + assertThat(opts.getExceptionHandler()).isNotNull(); + assertThat(opts.getMaxInFlight()).isNull(); + assertThat(opts.isTrackReturned()).isFalse(); + assertThat(opts.getScheduler()).isEqualTo(Schedulers.immediate()); + assertThat(opts.getChannelMono()).isNull(); + assertThat(opts.getChannelCloseHandler()).isNull(); + } + + @Test + void trackReturned() { + var opts = new SendOptions().trackReturned(true); + assertThat(opts.isTrackReturned()).isTrue(); + } + + @Test + void maxInFlight() { + var opts = new SendOptions().maxInFlight(256); + assertThat(opts.getMaxInFlight()).isEqualTo(256); + } + + @Test + void maxInFlightWithScheduler() { + var scheduler = Schedulers.boundedElastic(); + var opts = new SendOptions().maxInFlight(128, scheduler); + assertThat(opts.getMaxInFlight()).isEqualTo(128); + assertThat(opts.getScheduler()).isSameAs(scheduler); + } + + @Test + void channelMono() { + Mono mono = Mono.just(mock(Channel.class)); + var opts = new SendOptions().channelMono(mono); + assertThat(opts.getChannelMono()).isSameAs(mono); + } + + @Test + void channelCloseHandler() { + BiConsumer handler = (s, c) -> {}; + var opts = new SendOptions().channelCloseHandler(handler); + assertThat(opts.getChannelCloseHandler()).isSameAs(handler); + } + + @Test + void channelPoolSetsMonoAndHandler() { + var pool = mock(ChannelPool.class); + Mono mono = Mono.just(mock(Channel.class)); + BiConsumer handler = (s, c) -> {}; + doReturn(mono).when(pool).getChannelMono(); + doReturn(handler).when(pool).getChannelCloseHandler(); + + var opts = new SendOptions().channelPool(pool); + assertThat(opts.getChannelMono()).isSameAs(mono); + assertThat(opts.getChannelCloseHandler()).isSameAs(handler); + } +} diff --git a/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/SenderOptionsTest.java b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/SenderOptionsTest.java new file mode 100644 index 00000000..54d3cdfa --- /dev/null +++ b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/SenderOptionsTest.java @@ -0,0 +1,132 @@ +package reactor.rabbitmq; + +import com.rabbitmq.client.Channel; +import com.rabbitmq.client.Connection; +import com.rabbitmq.client.ConnectionFactory; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.core.publisher.SignalType; +import reactor.core.scheduler.Schedulers; + +import java.time.Duration; +import java.util.function.BiConsumer; +import java.util.function.UnaryOperator; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +class SenderOptionsTest { + + @Test + void defaultValues() { + var opts = new SenderOptions(); + assertThat(opts.getConnectionFactory()).isNotNull(); + assertThat(opts.getConnectionMono()).isNull(); + assertThat(opts.getChannelMono()).isNull(); + assertThat(opts.getChannelCloseHandler()).isNull(); + assertThat(opts.getResourceManagementScheduler()).isNull(); + assertThat(opts.getConnectionSubscriptionScheduler()).isNull(); + assertThat(opts.getResourceManagementChannelMono()).isNull(); + assertThat(opts.getConnectionSupplier()).isNull(); + assertThat(opts.getConnectionMonoConfigurator()).isNotNull(); + assertThat(opts.getConnectionClosingTimeout()).isEqualTo(Duration.ofSeconds(30)); + } + + @Test + void connectionFactory() { + var factory = new ConnectionFactory(); + var opts = new SenderOptions().connectionFactory(factory); + assertThat(opts.getConnectionFactory()).isSameAs(factory); + } + + @Test + void connectionMono() { + Mono mono = Mono.just(mock(Connection.class)); + var opts = new SenderOptions().connectionMono(mono); + assertThat(opts.getConnectionMono()).isSameAs(mono); + } + + @Test + void channelMono() { + Mono mono = Mono.just(mock(Channel.class)); + var opts = new SenderOptions().channelMono(mono); + assertThat(opts.getChannelMono()).isSameAs(mono); + } + + @Test + void channelCloseHandler() { + BiConsumer handler = (s, c) -> {}; + var opts = new SenderOptions().channelCloseHandler(handler); + assertThat(opts.getChannelCloseHandler()).isSameAs(handler); + } + + @Test + void resourceManagementScheduler() { + var scheduler = Schedulers.boundedElastic(); + var opts = new SenderOptions().resourceManagementScheduler(scheduler); + assertThat(opts.getResourceManagementScheduler()).isSameAs(scheduler); + } + + @Test + void connectionSubscriptionScheduler() { + var scheduler = Schedulers.boundedElastic(); + var opts = new SenderOptions().connectionSubscriptionScheduler(scheduler); + assertThat(opts.getConnectionSubscriptionScheduler()).isSameAs(scheduler); + } + + @Test + void connectionSupplierWithFactory() throws Exception { + var connection = mock(Connection.class); + var opts = new SenderOptions().connectionSupplier(cf -> connection); + assertThat(opts.getConnectionSupplier()).isNotNull(); + assertThat(opts.getConnectionSupplier().apply(null)).isSameAs(connection); + } + + @Test + void connectionSupplierWithExplicitFactory() throws Exception { + var connection = mock(Connection.class); + var factory = new ConnectionFactory(); + var opts = new SenderOptions().connectionSupplier(factory, cf -> connection); + assertThat(opts.getConnectionSupplier().apply(null)).isSameAs(connection); + } + + @Test + void connectionMonoConfigurator() { + UnaryOperator> configurator = Mono::cache; + var opts = new SenderOptions().connectionMonoConfigurator(configurator); + assertThat(opts.getConnectionMonoConfigurator()).isSameAs(configurator); + } + + @Test + void connectionMonoConfiguratorDefaultIsIdentity() { + var opts = new SenderOptions(); + Mono mono = Mono.just(mock(Connection.class)); + assertThat(opts.getConnectionMonoConfigurator().apply(mono)).isSameAs(mono); + } + + @Test + void resourceManagementChannelMono() { + Mono mono = Mono.just(mock(Channel.class)); + var opts = new SenderOptions().resourceManagementChannelMono(mono); + assertThat(opts.getResourceManagementChannelMono()).isSameAs(mono); + } + + @Test + void connectionClosingTimeout() { + var opts = new SenderOptions().connectionClosingTimeout(Duration.ofMillis(500)); + assertThat(opts.getConnectionClosingTimeout()).isEqualTo(Duration.ofMillis(500)); + } + + @Test + void channelPool() { + var pool = mock(ChannelPool.class); + Mono mono = Mono.just(mock(Channel.class)); + BiConsumer handler = (s, c) -> {}; + doReturn(mono).when(pool).getChannelMono(); + doReturn(handler).when(pool).getChannelCloseHandler(); + + var opts = new SenderOptions().channelPool(pool); + assertThat(opts.getChannelMono()).isSameAs(mono); + assertThat(opts.getChannelCloseHandler()).isSameAs(handler); + } +} diff --git a/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/SenderTest.java b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/SenderTest.java new file mode 100644 index 00000000..f6e0a647 --- /dev/null +++ b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/SenderTest.java @@ -0,0 +1,496 @@ +package reactor.rabbitmq; + +import com.rabbitmq.client.AMQP; +import com.rabbitmq.client.Channel; +import com.rabbitmq.client.ConfirmListener; +import com.rabbitmq.client.Connection; +import com.rabbitmq.client.ReturnListener; +import com.rabbitmq.client.ShutdownSignalException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SenderTest { + + private Connection connection; + private Channel channel; + private Sender sender; + + @BeforeEach + void setUp() throws Exception { + connection = mock(Connection.class); + channel = mock(Channel.class); + when(connection.createChannel()).thenReturn(channel); + when(channel.isOpen()).thenReturn(true); + when(channel.getConnection()).thenReturn(connection); + } + + @AfterEach + void tearDown() { + if (sender != null) { + sender.close(); + } + } + + @Test + void sendPublishesMessages() throws Exception { + sender = new Sender(new SenderOptions().connectionMono(Mono.just(connection))); + Flux messages = Flux.range(0, 5) + .map(i -> new OutboundMessage("exch", "rk", ("msg" + i).getBytes())); + + sender.send(messages).block(Duration.ofSeconds(5)); + + verify(channel, times(5)) + .basicPublish(eq("exch"), eq("rk"), isNull(), any(byte[].class)); + } + + @Test + void sendWithPublishConfirmsAcksMessages() throws Exception { + when(channel.getNextPublishSeqNo()).thenReturn(1L, 2L, 3L); + doAnswer(invocation -> { + channel.getConnection(); // just to make it compile + return null; + }).when(channel).confirmSelect(); + + // Simulate confirm ack for all messages upon confirmSelect + doAnswer(invocation -> null) + .when(channel).addConfirmListener(any(com.rabbitmq.client.ConfirmListener.class)); + + sender = new Sender(new SenderOptions().connectionMono(Mono.just(connection))); + + // Since we can't easily test async confirms in a unit test, + // verify that confirmSelect is called and publish happens + Flux messages = Flux.just( + new OutboundMessage("exch", "rk", "msg".getBytes())); + + // Just verify the pipeline doesn't error out with setup + assertNotNull(sender.sendWithPublishConfirms(messages)); + } + + @Test + void declareQueueExecutesRpc() throws Exception { + var rpcFuture = new CompletableFuture(); + var command = mock(com.rabbitmq.client.Command.class); + var declareOk = mock(AMQP.Queue.DeclareOk.class); + when(command.getMethod()).thenReturn(declareOk); + rpcFuture.complete(command); + when(channel.asyncCompletableRpc(any())).thenReturn(rpcFuture); + + sender = new Sender(new SenderOptions().connectionMono(Mono.just(connection))); + + StepVerifier.create(sender.declare(QueueSpecification.queue("test-queue").durable(true))) + .expectNext(declareOk) + .verifyComplete(); + } + + @Test + void declareExchangeExecutesRpc() throws Exception { + var rpcFuture = new CompletableFuture(); + var command = mock(com.rabbitmq.client.Command.class); + var declareOk = mock(AMQP.Exchange.DeclareOk.class); + when(command.getMethod()).thenReturn(declareOk); + rpcFuture.complete(command); + when(channel.asyncCompletableRpc(any())).thenReturn(rpcFuture); + + sender = new Sender(new SenderOptions().connectionMono(Mono.just(connection))); + + StepVerifier.create(sender.declare(ExchangeSpecification.exchange("test-exchange").type("direct"))) + .expectNext(declareOk) + .verifyComplete(); + } + + @Test + void bindExecutesRpc() throws Exception { + var rpcFuture = new CompletableFuture(); + var command = mock(com.rabbitmq.client.Command.class); + var bindOk = mock(AMQP.Queue.BindOk.class); + when(command.getMethod()).thenReturn(bindOk); + rpcFuture.complete(command); + when(channel.asyncCompletableRpc(any())).thenReturn(rpcFuture); + + sender = new Sender(new SenderOptions().connectionMono(Mono.just(connection))); + + StepVerifier.create(sender.bind(BindingSpecification.binding("exch", "rk", "queue"))) + .expectNext(bindOk) + .verifyComplete(); + } + + @Test + void unbindExecutesRpc() throws Exception { + var rpcFuture = new CompletableFuture(); + var command = mock(com.rabbitmq.client.Command.class); + var unbindOk = mock(AMQP.Queue.UnbindOk.class); + when(command.getMethod()).thenReturn(unbindOk); + rpcFuture.complete(command); + when(channel.asyncCompletableRpc(any())).thenReturn(rpcFuture); + + sender = new Sender(new SenderOptions().connectionMono(Mono.just(connection))); + + StepVerifier.create( + sender.unbind(BindingSpecification.binding("exch", "rk", "queue")) + ) + .expectNext(unbindOk) + .verifyComplete(); + } + + @Test + void declareQueueWithNullName() throws Exception { + var rpcFuture = new CompletableFuture(); + var command = mock(com.rabbitmq.client.Command.class); + var declareOk = mock(AMQP.Queue.DeclareOk.class); + when(command.getMethod()).thenReturn(declareOk); + rpcFuture.complete(command); + when(channel.asyncCompletableRpc(any())).thenReturn(rpcFuture); + + sender = new Sender(new SenderOptions().connectionMono(Mono.just(connection))); + + StepVerifier.create(sender.declare(QueueSpecification.queue())) + .expectNext(declareOk) + .verifyComplete(); + } + + @Test + void channelMonoPriority() { + Mono senderChannelMono = Mono.just(mock(Channel.class)); + Mono sendChannelMono = Mono.just(mock(Channel.class)); + + sender = new Sender(new SenderOptions().connectionMono(Mono.just(connection))); + assertNotNull(sender.getChannelMono(new SendOptions())); + assertSame(sendChannelMono, sender.getChannelMono(new SendOptions().channelMono(sendChannelMono))); + + sender.close(); + sender = new Sender(new SenderOptions().connectionMono(Mono.just(connection)) + .channelMono(senderChannelMono)); + assertSame(senderChannelMono, sender.getChannelMono(new SendOptions())); + assertSame(sendChannelMono, sender.getChannelMono(new SendOptions().channelMono(sendChannelMono))); + } + + @Test + void closeIsIdempotent() { + sender = new Sender(new SenderOptions().connectionMono(Mono.just(connection))); + sender.close(); + sender.close(); + sender = null; // prevent double close in tearDown + } + + @Test + void connectionIsClosedWithTimeout() throws Exception { + Connection c = mock(Connection.class); + Channel ch = mock(Channel.class); + when(c.createChannel()).thenReturn(ch); + when(ch.isOpen()).thenReturn(true); + when(ch.getConnection()).thenReturn(c); + + var options = new SenderOptions() + .connectionSupplier(cf -> c) + .connectionClosingTimeout(Duration.ofMillis(5000)); + + sender = new Sender(options); + sender.send(Flux.range(1, 1) + .map(i -> new OutboundMessage("", "", new byte[0]))) + .block(Duration.ofSeconds(5)); + sender.close(); + + verify(c, times(1)).close(5000); + sender = null; + } + + @Test + void connectionClosedWithZeroTimeoutMeansNoTimeout() throws Exception { + Connection c = mock(Connection.class); + Channel ch = mock(Channel.class); + when(c.createChannel()).thenReturn(ch); + when(ch.isOpen()).thenReturn(true); + when(ch.getConnection()).thenReturn(c); + + var options = new SenderOptions() + .connectionSupplier(cf -> c) + .connectionClosingTimeout(Duration.ZERO); + + sender = new Sender(options); + sender.send(Flux.range(1, 1) + .map(i -> new OutboundMessage("", "", new byte[0]))) + .block(Duration.ofSeconds(5)); + sender.close(); + + verify(c, times(1)).close(-1); + sender = null; + } + + @Test + void sendWithExceptionHandlerCallsHandler() throws Exception { + doThrow(new IOException("publish failed")).when(channel) + .basicPublish(anyString(), anyString(), any(), any(byte[].class)); + sender = new Sender(new SenderOptions().connectionMono(Mono.just(connection))); + + var handlerCalled = new java.util.concurrent.atomic.AtomicBoolean(false); + var sendOptions = new SendOptions().exceptionHandler((ctx, ex) -> handlerCalled.set(true)); + + sender.send(Flux.just(new OutboundMessage("exch", "rk", "body".getBytes())), sendOptions) + .block(Duration.ofSeconds(5)); + + assertThat(handlerCalled.get()).isTrue(); + } + + @Test + void sendContextPublishDelegates() throws Exception { + var ctx = new Sender.SendContext<>(channel, new OutboundMessage("e", "r", "b".getBytes())); + assertThat(ctx.getChannel()).isSameAs(channel); + assertThat(ctx.getMessage().getExchange()).isEqualTo("e"); + + ctx.publish(); + verify(channel).basicPublish(eq("e"), eq("r"), isNull(), any(byte[].class)); + } + + @Test + void sendContextPublishWithDifferentMessage() throws Exception { + var ctx = new Sender.SendContext<>(channel, new OutboundMessage("e1", "r1", "b1".getBytes())); + var other = new OutboundMessage("e2", "r2", "b2".getBytes()); + ctx.publish(other); + verify(channel).basicPublish(eq("e2"), eq("r2"), isNull(), eq("b2".getBytes())); + } + + @Nested + class PublishConfirmTests { + + @BeforeEach + void setUpConfirm() { + when(channel.getNextPublishSeqNo()).thenReturn(1L, 2L, 3L, 4L, 5L); + } + + @Test + void publishConfirmSingleAck() throws Exception { + AtomicReference listenerRef = new AtomicReference<>(); + doAnswer(inv -> { + listenerRef.set(inv.getArgument(0)); + return null; + }).when(channel).addConfirmListener(any(ConfirmListener.class)); + + sender = new Sender(new SenderOptions().connectionMono(Mono.just(connection))); + + Flux messages = Flux.just( + new OutboundMessage("exch", "rk", "msg1".getBytes())); + + List> results = new ArrayList<>(); + var flux = sender.sendWithPublishConfirms(messages); + + flux.subscribe(results::add); + + // Simulate broker ack for delivery tag 1 + if (listenerRef.get() != null) { + listenerRef.get().handleAck(1, false); + } + + assertThat(results) + .hasSize(1) + .first() + .satisfies(r -> { + assertThat(r.ack()).isTrue(); + assertThat(r.returned()).isFalse(); + }); + } + + @Test + void publishConfirmMultipleAck() throws Exception { + AtomicReference listenerRef = new AtomicReference<>(); + doAnswer(inv -> { + listenerRef.set(inv.getArgument(0)); + return null; + }).when(channel).addConfirmListener(any(ConfirmListener.class)); + + sender = new Sender(new SenderOptions().connectionMono(Mono.just(connection))); + + Flux messages = Flux.just( + new OutboundMessage("exch", "rk", "m1".getBytes()), + new OutboundMessage("exch", "rk", "m2".getBytes())); + + List> results = new ArrayList<>(); + sender.sendWithPublishConfirms(messages).subscribe(results::add); + + // Simulate broker ack multiple for delivery tag 2 (confirms 1 and 2) + if (listenerRef.get() != null) { + listenerRef.get().handleAck(2, true); + } + + assertThat(results).hasSize(2).allMatch(OutboundMessageResult::ack); + } + + @Test + void publishConfirmNack() throws Exception { + AtomicReference listenerRef = new AtomicReference<>(); + doAnswer(inv -> { + listenerRef.set(inv.getArgument(0)); + return null; + }).when(channel).addConfirmListener(any(ConfirmListener.class)); + + sender = new Sender(new SenderOptions().connectionMono(Mono.just(connection))); + + Flux messages = Flux.just( + new OutboundMessage("exch", "rk", "m1".getBytes())); + + List> results = new ArrayList<>(); + sender.sendWithPublishConfirms(messages).subscribe(results::add); + + if (listenerRef.get() != null) { + listenerRef.get().handleNack(1, false); + } + + assertThat(results) + .hasSize(1) + .first() + .satisfies(r -> assertThat(r.ack()).isFalse()); + } + + @Test + void publishConfirmWithTrackReturned() throws Exception { + when(channel.getNextPublishSeqNo()).thenReturn(1L); + AtomicReference returnRef = new AtomicReference<>(); + doAnswer(inv -> null).when(channel).addConfirmListener(any(ConfirmListener.class)); + doAnswer(inv -> { + returnRef.set(inv.getArgument(0)); + return null; + }).when(channel).addReturnListener(any(ReturnListener.class)); + + sender = new Sender(new SenderOptions().connectionMono(Mono.just(connection))); + + var sendOptions = new SendOptions().trackReturned(true); + Flux messages = Flux.just( + new OutboundMessage("exch", "rk", "m1".getBytes())); + + List> results = new ArrayList<>(); + sender.sendWithPublishConfirms(messages, sendOptions).subscribe(results::add); + + // Simulate a return (message was returned by broker) + if (returnRef.get() != null) { + var headers = new HashMap(); + headers.put("reactor_rabbitmq_delivery_tag", 1L); + var props = new AMQP.BasicProperties.Builder().headers(headers).build(); + returnRef.get().handleReturn( + 312, "NO_ROUTE", "exch", "rk", props, "m1".getBytes() + ); + } + + assertThat(results) + .hasSize(1) + .first() + .satisfies(r -> { + assertThat(r.returned()).isTrue(); + assertThat(r.ack()).isTrue(); + }); + } + + @Test + void publishConfirmShutdownNacksUnpublished() { + AtomicReference shutdownRef = new AtomicReference<>(); + doAnswer(inv -> { + shutdownRef.set(inv.getArgument(0)); + return null; + }).when(channel).addShutdownListener(any(com.rabbitmq.client.ShutdownListener.class)); + doAnswer(inv -> null).when(channel).addConfirmListener(any(ConfirmListener.class)); + + sender = new Sender(new SenderOptions().connectionMono(Mono.just(connection))); + + Flux messages = Flux.just( + new OutboundMessage("exch", "rk", "m1".getBytes())); + + AtomicReference errorRef = new AtomicReference<>(); + sender.sendWithPublishConfirms(messages).subscribe(r -> {}, errorRef::set); + + // Simulate channel shutdown (soft error, not by application) + if (shutdownRef.get() != null) { + shutdownRef.get().shutdownCompleted( + new ShutdownSignalException(false, false, null, channel)); + } + + // Shutdown on a soft non-application error should cause an onError + assertThat(errorRef.get()).isInstanceOf(ShutdownSignalException.class); + } + + @Test + void publishConfirmOnPublishExceptionInvokesExceptionHandler() throws Exception { + when(channel.getNextPublishSeqNo()).thenReturn(1L); + doThrow(new IOException("publish failed")).when(channel) + .basicPublish(anyString(), anyString(), anyBoolean(), any(), any(byte[].class)); + doAnswer(inv -> null).when(channel).addConfirmListener(any(ConfirmListener.class)); + + sender = new Sender(new SenderOptions().connectionMono(Mono.just(connection))); + + // Default exception handler is RetrySendingExceptionHandler which will retry and eventually timeout + // Use a noop handler that just throws RabbitFluxRetryTimeoutException + var sendOptions = new SendOptions().exceptionHandler((ctx, ex) -> { + throw new RabbitFluxRetryTimeoutException("timed out", ex); + }); + + List> results = new ArrayList<>(); + sender.sendWithPublishConfirms( + Flux.just(new OutboundMessage("exch", "rk", "m".getBytes())), sendOptions + ).subscribe(results::add); + + // On RabbitFluxRetryTimeoutException, the subscriber emits a nack result + assertThat(results) + .hasSize(1) + .first() + .satisfies(r -> assertThat(r.ack()).isFalse()); + } + + @Test + void confirmSendContextPublishTracksDeliveryTag() throws Exception { + when(channel.getNextPublishSeqNo()).thenReturn(42L); + AtomicReference confirmRef = new AtomicReference<>(); + doAnswer(inv -> { + confirmRef.set(inv.getArgument(0)); + return null; + }).when(channel).addConfirmListener(any(ConfirmListener.class)); + + sender = new Sender(new SenderOptions().connectionMono(Mono.just(connection))); + + var sendOptions = new SendOptions().trackReturned(true); + Flux messages = Flux.just( + new OutboundMessage("exch", "rk", "m".getBytes())); + + List> results = new ArrayList<>(); + sender.sendWithPublishConfirms(messages, sendOptions).subscribe(results::add); + + // Verify that basicPublish was called with mandatory=true (trackReturned) + // and that properties contain the delivery tag header + ArgumentCaptor propsCaptor = ArgumentCaptor.forClass(AMQP.BasicProperties.class); + verify(channel).basicPublish(eq("exch"), eq("rk"), eq(true), propsCaptor.capture(), any(byte[].class)); + + var props = propsCaptor.getValue(); + assertThat(props.getHeaders()).containsEntry("reactor_rabbitmq_delivery_tag", 42L); + + // Confirm it to complete + if (confirmRef.get() != null) { + confirmRef.get().handleAck(42, false); + } + } + } +} diff --git a/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/UtilsTest.java b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/UtilsTest.java new file mode 100644 index 00000000..06fa37e0 --- /dev/null +++ b/async/reactor-rabbitmq/src/test/java/reactor/rabbitmq/UtilsTest.java @@ -0,0 +1,61 @@ +package reactor.rabbitmq; + +import com.rabbitmq.client.Channel; +import com.rabbitmq.client.Connection; +import com.rabbitmq.client.RecoverableChannel; +import com.rabbitmq.client.RecoverableConnection; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class UtilsTest { + + @Test + void cacheReturnsElementIndefinitely() { + var cached = Utils.cache(Mono.just("value")); + assertThat(cached.block(Duration.ofSeconds(1))).isEqualTo("value"); + assertThat(cached.block(Duration.ofSeconds(1))).isEqualTo("value"); + } + + @Test + void cacheDoesNotCacheErrors() { + var counter = new java.util.concurrent.atomic.AtomicInteger(0); + var mono = Utils.cache(Mono.defer(() -> { + if (counter.incrementAndGet() == 1) { + return Mono.error(new RuntimeException("first call")); + } + return Mono.just("ok"); + })); + + assertThat(mono.onErrorResume(e -> Mono.empty()).block(Duration.ofSeconds(1))).isNull(); + assertThat(mono.block(Duration.ofSeconds(1))).isEqualTo("ok"); + } + + @Test + void isRecoverableConnectionReturnsTrueForRecoverable() { + var recoverable = mock(RecoverableConnection.class); + assertThat(Utils.isRecoverable((Connection) recoverable)).isTrue(); + } + + @Test + void isRecoverableConnectionReturnsFalseForNonRecoverable() { + var connection = mock(Connection.class); + assertThat(Utils.isRecoverable(connection)).isFalse(); + } + + @Test + void isRecoverableChannelReturnsTrueForRecoverable() { + var recoverable = mock(RecoverableChannel.class); + assertThat(Utils.isRecoverable((Channel) recoverable)).isTrue(); + } + + @Test + void isRecoverableChannelReturnsFalseForNonRecoverable() { + var channel = mock(Channel.class); + assertThat(Utils.isRecoverable(channel)).isFalse(); + } +} diff --git a/docs/docs/reactive-commons/configuration_properties/1-rabbitmq.md b/docs/docs/reactive-commons/configuration_properties/1-rabbitmq.md index b6d553fc..665f8069 100644 --- a/docs/docs/reactive-commons/configuration_properties/1-rabbitmq.md +++ b/docs/docs/reactive-commons/configuration_properties/1-rabbitmq.md @@ -400,10 +400,10 @@ package sample; import co.com.mypackage.usecase.MyUseCase; import lombok.RequiredArgsConstructor; import lombok.extern.java.Log; -import org.reactivecommons.async.rabbit.communications.MyOutboundMessage; import org.reactivecommons.async.rabbit.communications.UnroutableMessageHandler; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; +import reactor.rabbitmq.OutboundMessage; import reactor.rabbitmq.OutboundMessageResult; import java.nio.charset.StandardCharsets; @@ -416,8 +416,8 @@ public class ResendUnroutableMessageHandler implements UnroutableMessageHandler private final MyUseCase useCase; @Override - public Mono processMessage(OutboundMessageResult result) { - var returned = result.getOutboundMessage(); + public Mono processMessage(OutboundMessageResult result) { + var returned = result.outboundMessage(); log.severe("Unroutable message: exchange=" + returned.getExchange() + ", routingKey=" + returned.getRoutingKey() + ", body=" + new String(returned.getBody(), StandardCharsets.UTF_8) @@ -443,10 +443,10 @@ Therefore, it is recommended to verify or create the queue beforehand to ensure ```java package sample; +import lombok.extern.java.Log; import org.reactivecommons.api.domain.Command; import org.reactivecommons.async.api.DirectAsyncGateway; import org.reactivecommons.async.impl.config.annotations.EnableDirectAsyncGateway; -import org.reactivecommons.async.rabbit.communications.MyOutboundMessage; import org.reactivecommons.async.rabbit.communications.UnroutableMessageHandler; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -457,6 +457,7 @@ import tools.jackson.core.type.TypeReference; import tools.jackson.databind.JsonNode; import tools.jackson.databind.json.JsonMapper; +@Log @Component @EnableDirectAsyncGateway public class ResendUnroutableMessageHandler implements UnroutableMessageHandler { @@ -483,8 +484,8 @@ public class ResendUnroutableMessageHandler implements UnroutableMessageHandler } @Override - public Mono processMessage(OutboundMessageResult result) { - OutboundMessage returned = result.getOutboundMessage(); + public Mono processMessage(OutboundMessageResult result) { + OutboundMessage returned = result.outboundMessage(); try { // The unroutable message is a command, so the message body is deserialized to the Command class. // Use the DomainEvent class for domain events and the AsyncQuery class for asynchronous queries. diff --git a/gradle.properties b/gradle.properties index a07044c4..1f600a9b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ version=7.0.6 -toPublish=domain-events-api,async-commons-api,async-commons,cloudevents-json-jackson,async-rabbit,async-commons-rabbit-standalone,async-commons-rabbit-starter,async-kafka,async-kafka-starter,async-commons-starter +toPublish=domain-events-api,async-commons-api,async-commons,cloudevents-json-jackson,reactor-rabbitmq,async-rabbit,async-commons-rabbit-starter,async-kafka,async-kafka-starter,async-commons-starter onlyUpdater=true \ No newline at end of file diff --git a/starters/async-rabbit-standalone/async-commons-rabbit-standalone.gradle b/starters/async-rabbit-standalone/async-commons-rabbit-standalone.gradle deleted file mode 100644 index 1306f86e..00000000 --- a/starters/async-rabbit-standalone/async-commons-rabbit-standalone.gradle +++ /dev/null @@ -1,8 +0,0 @@ -ext { - artifactId = 'async-commons-rabbit-standalone' - artifactDescription = 'Async Commons Standalone Config' -} - -dependencies { - api project(':async-rabbit') -} \ No newline at end of file diff --git a/starters/async-rabbit-standalone/src/main/java/org/reactivecommons/async/rabbit/standalone/config/DirectAsyncGatewayConfig.java b/starters/async-rabbit-standalone/src/main/java/org/reactivecommons/async/rabbit/standalone/config/DirectAsyncGatewayConfig.java deleted file mode 100644 index 0beea22f..00000000 --- a/starters/async-rabbit-standalone/src/main/java/org/reactivecommons/async/rabbit/standalone/config/DirectAsyncGatewayConfig.java +++ /dev/null @@ -1,70 +0,0 @@ -package org.reactivecommons.async.rabbit.standalone.config; - -import io.micrometer.core.instrument.MeterRegistry; -import lombok.RequiredArgsConstructor; -import org.reactivecommons.async.commons.config.BrokerConfig; -import org.reactivecommons.async.commons.converters.MessageConverter; -import org.reactivecommons.async.commons.reply.ReactiveReplyRouter; -import org.reactivecommons.async.rabbit.RabbitDirectAsyncGateway; -import org.reactivecommons.async.rabbit.communications.ReactiveMessageListener; -import org.reactivecommons.async.rabbit.communications.ReactiveMessageSender; -import org.reactivecommons.async.rabbit.listeners.ApplicationReplyListener; - -import java.nio.ByteBuffer; -import java.util.Base64; -import java.util.UUID; - -@RequiredArgsConstructor -public class DirectAsyncGatewayConfig { - - private String directMessagesExchangeName; - private String globalReplyExchangeName; - private String appName; - - - public RabbitDirectAsyncGateway rabbitDirectAsyncGateway(BrokerConfig config, ReactiveReplyRouter router, - ReactiveMessageSender rSender, MessageConverter converter, - MeterRegistry meterRegistry) { - return new RabbitDirectAsyncGateway(config, router, rSender, directMessagesExchangeName, converter, - meterRegistry); - } - - public ApplicationReplyListener msgListener(ReactiveReplyRouter router, BrokerConfig config, - ReactiveMessageListener listener, boolean createTopology) { - final ApplicationReplyListener replyListener = new ApplicationReplyListener(router, listener, generateName(), - globalReplyExchangeName, createTopology); - replyListener.startListening(config.getRoutingKey()); - return replyListener; - } - - - public BrokerConfig brokerConfig() { - return new BrokerConfig(); - } - - - public ReactiveReplyRouter router() { - return new ReactiveReplyRouter(); - } - - public String generateName() { - UUID uuid = UUID.randomUUID(); - ByteBuffer bb = ByteBuffer.wrap(new byte[16]); - bb.putLong(uuid.getMostSignificantBits()) - .putLong(uuid.getLeastSignificantBits()); - // Convert to base64 and remove trailing = - return this.appName + encodeToUrlSafeString(bb.array()) - .replace("=", ""); - } - - public static String encodeToUrlSafeString(byte[] src) { - return new String(encodeUrlSafe(src)); - } - - public static byte[] encodeUrlSafe(byte[] src) { - if (src.length == 0) { - return src; - } - return Base64.getUrlEncoder().encode(src); - } -} diff --git a/starters/async-rabbit-standalone/src/main/java/org/reactivecommons/async/rabbit/standalone/config/EventBusConfig.java b/starters/async-rabbit-standalone/src/main/java/org/reactivecommons/async/rabbit/standalone/config/EventBusConfig.java deleted file mode 100644 index 959e19d9..00000000 --- a/starters/async-rabbit-standalone/src/main/java/org/reactivecommons/async/rabbit/standalone/config/EventBusConfig.java +++ /dev/null @@ -1,20 +0,0 @@ -package org.reactivecommons.async.rabbit.standalone.config; - -import lombok.RequiredArgsConstructor; -import org.reactivecommons.api.domain.DomainEventBus; -import org.reactivecommons.async.rabbit.RabbitDomainEventBus; -import org.reactivecommons.async.rabbit.communications.ReactiveMessageSender; - -import static reactor.rabbitmq.ExchangeSpecification.exchange; - - -@RequiredArgsConstructor -public class EventBusConfig { - - private final String domainEventsExchangeName; - - public DomainEventBus domainEventBus(ReactiveMessageSender sender) { - sender.getTopologyCreator().declare(exchange(domainEventsExchangeName).durable(true).type("topic")).subscribe(); - return new RabbitDomainEventBus(sender, domainEventsExchangeName); - } -} diff --git a/starters/async-rabbit-standalone/src/main/java/org/reactivecommons/async/rabbit/standalone/config/RabbitProperties.java b/starters/async-rabbit-standalone/src/main/java/org/reactivecommons/async/rabbit/standalone/config/RabbitProperties.java deleted file mode 100644 index 93c9a1f3..00000000 --- a/starters/async-rabbit-standalone/src/main/java/org/reactivecommons/async/rabbit/standalone/config/RabbitProperties.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.reactivecommons.async.rabbit.standalone.config; - -import lombok.Data; - -@Data -public class RabbitProperties { - private String host = "localhost"; - private int port = 5672; - private String username = "guest"; - private String password = "guest"; //NOSONAR - private String virtualHost; - private Integer channelPoolMaxCacheSize; -} diff --git a/starters/async-rabbit-standalone/src/test/java/.gitkeep b/starters/async-rabbit-standalone/src/test/java/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/starters/async-rabbit-starter/src/main/java/org/reactivecommons/async/rabbit/RabbitMQSetupUtils.java b/starters/async-rabbit-starter/src/main/java/org/reactivecommons/async/rabbit/RabbitMQSetupUtils.java index 138a7598..081577c4 100644 --- a/starters/async-rabbit-starter/src/main/java/org/reactivecommons/async/rabbit/RabbitMQSetupUtils.java +++ b/starters/async-rabbit-starter/src/main/java/org/reactivecommons/async/rabbit/RabbitMQSetupUtils.java @@ -29,7 +29,6 @@ import reactor.rabbitmq.ChannelPool; import reactor.rabbitmq.ChannelPoolFactory; import reactor.rabbitmq.ChannelPoolOptions; -import reactor.rabbitmq.RabbitFlux; import reactor.rabbitmq.Receiver; import reactor.rabbitmq.ReceiverOptions; import reactor.rabbitmq.Sender; @@ -95,7 +94,7 @@ public static ReactiveMessageSender createMessageSender(ConnectionFactoryProvide AsyncProps props, MessageConverter converter, UnroutableMessageNotifier unroutableMessageNotifier) { - final Sender sender = RabbitFlux.createSender(reactiveCommonsSenderOptions(props.getAppName(), provider, + final Sender sender = new Sender(reactiveCommonsSenderOptions(props.getAppName(), provider, props.getConnectionProperties())); return new ReactiveMessageSender(sender, props.getAppName(), converter, new TopologyCreator(sender, props.getQueueType()), @@ -106,8 +105,8 @@ public static ReactiveMessageSender createMessageSender(ConnectionFactoryProvide public static ReactiveMessageListener createMessageListener(ConnectionFactoryProvider provider, AsyncProps props) { final Mono connection = createConnectionMono(provider.getConnectionFactory(), props.getAppName()); - final Receiver receiver = RabbitFlux.createReceiver(new ReceiverOptions().connectionMono(connection)); - final Sender sender = RabbitFlux.createSender(new SenderOptions().connectionMono(connection)); + final Receiver receiver = new Receiver(new ReceiverOptions().connectionMono(connection)); + final Sender sender = new Sender(new SenderOptions().connectionMono(connection)); return new ReactiveMessageListener(receiver, new TopologyCreator(sender, props.getQueueType()), @@ -118,7 +117,7 @@ public static ReactiveMessageListener createMessageListener(ConnectionFactoryPro public static TopologyCreator createTopologyCreator(AsyncProps props, ConnectionFactoryCustomizer cfCustomizer) { ConnectionFactoryProvider provider = connectionFactoryProvider(props, cfCustomizer); final Mono connection = createConnectionMono(provider.getConnectionFactory(), props.getAppName()); - final Sender sender = RabbitFlux.createSender(new SenderOptions().connectionMono(connection)); + final Sender sender = new Sender(new SenderOptions().connectionMono(connection)); return new TopologyCreator(sender, props.getQueueType()); } diff --git a/starters/async-rabbit-starter/src/test/java/org/reactivecommons/async/rabbit/RabbitMQBrokerProviderTest.java b/starters/async-rabbit-starter/src/test/java/org/reactivecommons/async/rabbit/RabbitMQBrokerProviderTest.java index 14b96ee9..cb5ba4d6 100644 --- a/starters/async-rabbit-starter/src/test/java/org/reactivecommons/async/rabbit/RabbitMQBrokerProviderTest.java +++ b/starters/async-rabbit-starter/src/test/java/org/reactivecommons/async/rabbit/RabbitMQBrokerProviderTest.java @@ -100,7 +100,7 @@ void init() { void shouldCreateDomainEventBus() { when(sender.getTopologyCreator()).thenReturn(creator); when(creator.declare(any(ExchangeSpecification.class))) - .thenReturn(Mono.just(mock(AMQP.Exchange.DeclareOk.class))); + .thenReturn(Mono.empty()); // Act DomainEventBus domainBus = brokerProvider.getDomainBus(); // Assert @@ -112,9 +112,9 @@ void shouldCreateDirectAsyncGateway() { when(sender.getTopologyCreator()).thenReturn(creator); when(listener.topologyCreator()).thenReturn(creator); when(creator.declare(any(ExchangeSpecification.class))) - .thenReturn(Mono.just(mock(AMQP.Exchange.DeclareOk.class))); + .thenReturn(Mono.empty()); when(creator.bind(any(BindingSpecification.class))) - .thenReturn(Mono.just(mock(AMQP.Queue.BindOk.class))); + .thenReturn(Mono.empty()); when(creator.declare(any(QueueSpecification.class))) .thenReturn(Mono.just(mock(AMQP.Queue.DeclareOk.class))); when(listener.receiver()).thenReturn(receiver); @@ -129,7 +129,7 @@ void shouldCreateDirectAsyncGateway() { void shouldListenDomainEvents() { when(listener.topologyCreator()).thenReturn(creator); when(creator.declare(any(ExchangeSpecification.class))) - .thenReturn(Mono.just(mock(AMQP.Exchange.DeclareOk.class))); + .thenReturn(Mono.empty()); when(creator.declareQueue(any(String.class), any())).thenReturn(Mono.just(mock(AMQP.Queue.DeclareOk.class))); when(listener.receiver()).thenReturn(receiver); when(listener.maxConcurrency()).thenReturn(1); @@ -146,7 +146,7 @@ void shouldListenNotificationEvents() { when(handlerResolver.hasNotificationListeners()).thenReturn(true); when(listener.topologyCreator()).thenReturn(creator); when(creator.declare(any(ExchangeSpecification.class))) - .thenReturn(Mono.just(mock(AMQP.Exchange.DeclareOk.class))); + .thenReturn(Mono.empty()); when(creator.declare(any(QueueSpecification.class))) .thenReturn(Mono.just(mock(AMQP.Queue.DeclareOk.class))); when(listener.receiver()).thenReturn(receiver); @@ -165,11 +165,11 @@ void shouldListenCommands() { when(handlerResolver.hasCommandHandlers()).thenReturn(true); when(listener.topologyCreator()).thenReturn(creator); when(creator.declare(any(ExchangeSpecification.class))) - .thenReturn(Mono.just(mock(AMQP.Exchange.DeclareOk.class))); + .thenReturn(Mono.empty()); when(creator.declareQueue(any(String.class), any())) .thenReturn(Mono.just(mock(AMQP.Queue.DeclareOk.class))); when(creator.bind(any(BindingSpecification.class))) - .thenReturn(Mono.just(mock(AMQP.Queue.BindOk.class))); + .thenReturn(Mono.empty()); when(listener.receiver()).thenReturn(receiver); when(listener.maxConcurrency()).thenReturn(1); when(receiver.consumeManualAck(any(String.class), any())).thenReturn(Flux.never()); @@ -184,11 +184,11 @@ void shouldListenQueries() { when(handlerResolver.hasQueryHandlers()).thenReturn(true); when(listener.topologyCreator()).thenReturn(creator); when(creator.declare(any(ExchangeSpecification.class))) - .thenReturn(Mono.just(mock(AMQP.Exchange.DeclareOk.class))); + .thenReturn(Mono.empty()); when(creator.declareQueue(any(String.class), any())) .thenReturn(Mono.just(mock(AMQP.Queue.DeclareOk.class))); when(creator.bind(any(BindingSpecification.class))) - .thenReturn(Mono.just(mock(AMQP.Queue.BindOk.class))); + .thenReturn(Mono.empty()); when(listener.receiver()).thenReturn(receiver); when(listener.maxConcurrency()).thenReturn(1); when(receiver.consumeManualAck(any(String.class), any())).thenReturn(Flux.never()); diff --git a/starters/async-rabbit-starter/src/test/java/org/reactivecommons/async/rabbit/config/spring/RabbitPropertiesBaseTest.java b/starters/async-rabbit-starter/src/test/java/org/reactivecommons/async/rabbit/config/spring/RabbitPropertiesBaseTest.java new file mode 100644 index 00000000..1a5fdd56 --- /dev/null +++ b/starters/async-rabbit-starter/src/test/java/org/reactivecommons/async/rabbit/config/spring/RabbitPropertiesBaseTest.java @@ -0,0 +1,206 @@ +package org.reactivecommons.async.rabbit.config.spring; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class RabbitPropertiesBaseTest { + + private final RabbitPropertiesBase props = new RabbitPropertiesBase(); + + @Test + void defaultHost() { + assertThat(props.getHost()).isEqualTo("localhost"); + } + + @Test + void defaultPort() { + assertThat(props.getPort()).isEqualTo(5672); + } + + @Test + void defaultCredentials() { + assertThat(props.getUsername()).isEqualTo("guest"); + assertThat(props.getPassword()).isEqualTo("guest"); + } + + @Test + void determineHostWithoutAddresses() { + assertThat(props.determineHost()).isEqualTo("localhost"); + } + + @Test + void determinePortWithoutAddresses() { + assertThat(props.determinePort()).isEqualTo(5672); + } + + @Test + void determineAddressesWithoutParsed() { + assertThat(props.determineAddresses()).isEqualTo("localhost:5672"); + } + + @Test + void determineUsernameWithoutAddresses() { + assertThat(props.determineUsername()).isEqualTo("guest"); + } + + @Test + void determinePasswordWithoutAddresses() { + assertThat(props.determinePassword()).isEqualTo("guest"); + } + + @Test + void determineVirtualHostWithoutAddresses() { + assertThat(props.determineVirtualHost()).isNull(); + } + + @Test + void setVirtualHostEmpty() { + props.setVirtualHost(""); + assertThat(props.getVirtualHost()).isEqualTo("/"); + } + + @Test + void setVirtualHostNonEmpty() { + props.setVirtualHost("myVhost"); + assertThat(props.getVirtualHost()).isEqualTo("myVhost"); + } + + @Nested + class WithAddresses { + + @Test + void parseSimpleAddress() { + props.setAddresses("myhost:5673"); + assertThat(props.determineHost()).isEqualTo("myhost"); + assertThat(props.determinePort()).isEqualTo(5673); + assertThat(props.determineAddresses()).isEqualTo("myhost:5673"); + } + + @Test + void parseAddressWithDefaultPort() { + props.setAddresses("myhost"); + assertThat(props.determineHost()).isEqualTo("myhost"); + assertThat(props.determinePort()).isEqualTo(5672); + } + + @Test + void parseMultipleAddresses() { + props.setAddresses("host1:5672,host2:5673"); + assertThat(props.determineAddresses()).isEqualTo("host1:5672,host2:5673"); + assertThat(props.determineHost()).isEqualTo("host1"); + assertThat(props.determinePort()).isEqualTo(5672); + } + + @Test + void parseAmqpPrefixed() { + props.setAddresses("amqp://myhost:5674"); + assertThat(props.determineHost()).isEqualTo("myhost"); + assertThat(props.determinePort()).isEqualTo(5674); + } + + @Test + void parseWithCredentials() { + props.setAddresses("user:pass@myhost:5675"); + assertThat(props.determineUsername()).isEqualTo("user"); + assertThat(props.determinePassword()).isEqualTo("pass"); + assertThat(props.determineHost()).isEqualTo("myhost"); + } + + @Test + void parseWithUsernameOnly() { + props.setAddresses("user@myhost:5675"); + assertThat(props.determineHost()).isEqualTo("myhost"); + } + + @Test + void parseWithVirtualHost() { + props.setAddresses("myhost:5672/myvhost"); + assertThat(props.determineVirtualHost()).isEqualTo("myvhost"); + } + + @Test + void parseWithEmptyVirtualHost() { + props.setAddresses("myhost:5672/"); + assertThat(props.determineVirtualHost()).isEqualTo("/"); + } + + @Test + void usernameFromAddressFallsBackToDefault() { + props.setAddresses("myhost:5672"); + assertThat(props.determineUsername()).isEqualTo("guest"); + } + + @Test + void passwordFromAddressFallsBackToDefault() { + props.setAddresses("myhost:5672"); + assertThat(props.determinePassword()).isEqualTo("guest"); + } + + @Test + void virtualHostFallsBackToDefault() { + props.setAddresses("myhost:5672"); + props.setVirtualHost("fallback"); + assertThat(props.determineVirtualHost()).isEqualTo("fallback"); + } + + @Test + void fullAmqpUri() { + props.setAddresses("amqp://admin:secret@broker:5676/production"); + assertThat(props.determineHost()).isEqualTo("broker"); + assertThat(props.determinePort()).isEqualTo(5676); + assertThat(props.determineUsername()).isEqualTo("admin"); + assertThat(props.determinePassword()).isEqualTo("secret"); + assertThat(props.determineVirtualHost()).isEqualTo("production"); + } + } + + @Test + void sslDefaults() { + var ssl = props.getSsl(); + assertThat(ssl.isEnabled()).isFalse(); + assertThat(ssl.getKeyStoreType()).isEqualTo("PKCS12"); + assertThat(ssl.getTrustStoreType()).isEqualTo("JKS"); + assertThat(ssl.isValidateServerCertificate()).isTrue(); + assertThat(ssl.isVerifyHostname()).isTrue(); + } + + @Test + void cacheDefaults() { + var cache = props.getCache(); + assertThat(cache.getChannel()).isNotNull(); + assertThat(cache.getConnection()).isNotNull(); + assertThat(cache.getConnection().getMode()).isEqualTo("CHANNEL"); + } + + @Test + void listenerDefaults() { + var listener = props.getListener(); + assertThat(listener.getType()).isEqualTo(RabbitPropertiesBase.ContainerType.SIMPLE); + assertThat(listener.getSimple()).isNotNull(); + assertThat(listener.getDirect()).isNotNull(); + assertThat(listener.getSimple().isMissingQueuesFatal()).isTrue(); + assertThat(listener.getDirect().isMissingQueuesFatal()).isFalse(); + } + + @Test + void templateDefaults() { + var template = props.getTemplate(); + assertThat(template.getExchange()).isEmpty(); + assertThat(template.getRoutingKey()).isEmpty(); + } + + @Test + void retryDefaults() { + var retry = props.getTemplate().getRetry(); + assertThat(retry.isEnabled()).isFalse(); + assertThat(retry.getMaxAttempts()).isEqualTo(3); + } + + @Test + void listenerRetryDefaults() { + var listenerRetry = props.getListener().getSimple().getRetry(); + assertThat(listenerRetry.isStateless()).isTrue(); + } +} diff --git a/starters/async-rabbit-starter/src/test/java/org/reactivecommons/async/starter/impl/common/rabbit/RabbitMQConfigTest.java b/starters/async-rabbit-starter/src/test/java/org/reactivecommons/async/starter/impl/common/rabbit/RabbitMQConfigTest.java index 4a85aee1..4f9524b9 100644 --- a/starters/async-rabbit-starter/src/test/java/org/reactivecommons/async/starter/impl/common/rabbit/RabbitMQConfigTest.java +++ b/starters/async-rabbit-starter/src/test/java/org/reactivecommons/async/starter/impl/common/rabbit/RabbitMQConfigTest.java @@ -9,7 +9,6 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.reactivecommons.async.rabbit.ConnectionFactoryCustomizer; import org.reactivecommons.async.rabbit.RabbitMQBrokerProviderFactory; -import org.reactivecommons.async.rabbit.communications.MyOutboundMessage; import org.reactivecommons.async.rabbit.communications.UnroutableMessageHandler; import org.reactivecommons.async.rabbit.communications.UnroutableMessageNotifier; import org.reactivecommons.async.rabbit.communications.UnroutableMessageProcessor; @@ -22,6 +21,7 @@ import org.reactivecommons.async.starter.config.ReactiveCommonsListenersConfig; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import reactor.rabbitmq.OutboundMessage; import reactor.rabbitmq.OutboundMessageResult; import reactor.test.StepVerifier; @@ -50,10 +50,10 @@ class RabbitMQConfigTest { private ConnectionManager manager; @Mock - private OutboundMessageResult resultMock; + private OutboundMessageResult resultMock; @Mock - private MyOutboundMessage outboundMessageMock; + private OutboundMessage outboundMessageMock; @Mock private UnroutableMessageNotifier unroutableMessageNotifier; @@ -79,7 +79,7 @@ void shouldHasManager() { @Test void shouldProcessAndLogWhenMessageIsReturned() { UnroutableMessageHandler handler = rabbitMQConfig.defaultUnroutableMessageProcessor(unroutableMessageNotifier); - when(resultMock.getOutboundMessage()).thenReturn(outboundMessageMock); + when(resultMock.outboundMessage()).thenReturn(outboundMessageMock); when(outboundMessageMock.getExchange()).thenReturn("test.exchange"); when(outboundMessageMock.getRoutingKey()).thenReturn("test.key"); when(outboundMessageMock.getBody()).thenReturn("test message".getBytes(StandardCharsets.UTF_8)); @@ -87,7 +87,7 @@ void shouldProcessAndLogWhenMessageIsReturned() { StepVerifier.create(handler.processMessage(resultMock)).verifyComplete(); - verify(resultMock).getOutboundMessage(); + verify(resultMock).outboundMessage(); verify(outboundMessageMock).getExchange(); verify(outboundMessageMock).getRoutingKey(); verify(outboundMessageMock).getBody();