diff --git a/.mvn/extensions.xml b/.mvn/extensions.xml index 25506efe4..e871cd89e 100644 --- a/.mvn/extensions.xml +++ b/.mvn/extensions.xml @@ -3,6 +3,6 @@ io.spring.develocity.conventions develocity-conventions-maven-extension - 0.0.26 + 0.0.27 diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties index 216df0589..bb58b3c26 100644 --- a/.mvn/wrapper/maven-wrapper.properties +++ b/.mvn/wrapper/maven-wrapper.properties @@ -1,3 +1,3 @@ wrapperVersion=3.3.4 distributionType=only-script -distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.10.0/apache-maven-3.10.0-bin.zip diff --git a/pom.xml b/pom.xml index 75c804903..8421c7b6f 100644 --- a/pom.xml +++ b/pom.xml @@ -138,6 +138,12 @@ limitations under the License. + + + + releaseTrain + + diff --git a/spring-modulith-core/src/main/java/org/springframework/modulith/core/FormattableType.java b/spring-modulith-core/src/main/java/org/springframework/modulith/core/FormattableType.java index 47baca8d3..718ca2ae3 100644 --- a/spring-modulith-core/src/main/java/org/springframework/modulith/core/FormattableType.java +++ b/spring-modulith-core/src/main/java/org/springframework/modulith/core/FormattableType.java @@ -152,7 +152,7 @@ public static FormattableType of(ResolvableType type) { .toList(); return resolved != null - ? CACHE.computeIfAbsent(type.toString(), __ -> new FormattableType(resolved.getName(), generics)) + ? CACHE.computeIfAbsent(type.toString(), __ -> new FormattableType(resolved.getTypeName(), generics)) : WILDCARD; } diff --git a/spring-modulith-core/src/test/java/org/springframework/modulith/core/FormattableTypeUnitTests.java b/spring-modulith-core/src/test/java/org/springframework/modulith/core/FormattableTypeUnitTests.java index 71d476fec..e7ebf44d6 100644 --- a/spring-modulith-core/src/test/java/org/springframework/modulith/core/FormattableTypeUnitTests.java +++ b/spring-modulith-core/src/test/java/org/springframework/modulith/core/FormattableTypeUnitTests.java @@ -17,10 +17,15 @@ import static org.assertj.core.api.Assertions.*; +import java.lang.reflect.Method; import java.util.List; import java.util.Map; +import java.util.stream.Stream; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.NamedExecutable; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; import org.springframework.core.ResolvableType; import org.springframework.modulith.core.FormattableType.NonModuleTypeAbbreviation; @@ -128,6 +133,31 @@ void rendersDeclaredGenerics() throws Exception { assertThat(type.getAbbreviatedFullName()).isEqualTo("j.u.List>"); } + @TestFactory // GH-1901 + Stream handlesArrayTypesFromResolvableType() throws Exception { + + var method = Sample.class.getMethod("arrays", Integer[].class, byte[].class, Integer[][].class, List.class); + + var tests = Stream.of( + new $(method, 0, "j.l.Integer[]"), + new $(method, 1, "byte[]"), + new $(method, 2, "j.l.Integer[][]"), + new $(method, 3, "j.u.List")); + + return DynamicTest.stream(tests); + } + + @Test // GH-1901 + void rendersArrayTypesConsistentlyIndependentOfLookupOrder() { + + var fromResolvableType = FormattableType.of(ResolvableType.forClass(Sample[].class)); + var fromClass = FormattableType.of(Sample[].class); + + assertThat(fromClass.getFullName()) + .isEqualTo(fromResolvableType.getFullName()) + .isEqualTo("org.springframework.modulith.core.FormattableTypeUnitTests.Sample[]"); + } + interface Sample { List wildcarded(List parameterized); @@ -135,5 +165,24 @@ interface Sample { void classWildcard(Class type); List> genericReturnType(); + + void arrays(Integer[] integers, byte[] bytes, Integer[][] matrix, List list); + } + + record $(ResolvableType type, String expected) implements NamedExecutable { + + public $(Method method, int parameterIndex, String expected) { + this(ResolvableType.forMethodParameter(method, parameterIndex), expected); + } + + @Override + public final String toString() { + return "%s renders as %s".formatted(type, expected); + } + + @Override + public void execute() throws Throwable { + assertThat(FormattableType.of(type).getAbbreviatedFullName()).isEqualTo(expected); + } } } diff --git a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/Documenter.java b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/Documenter.java index 690302d04..0fa4dcc1b 100644 --- a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/Documenter.java +++ b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/Documenter.java @@ -818,7 +818,7 @@ public DiagramOptions withStyle(DiagramStyle style) { /** * Configuration setting to define whether modules that do not have a relationship to any other module shall be - * retained in the diagrams created. The default is {@link ElementsWithoutRelationships#HIDDEN}. See + * retained in the diagrams created. The default is {@link ElementsWithoutRelationships#VISIBLE}. See * {@link DiagramOptions#withExclusions(Predicate)} for a more fine-grained way of defining which modules to exclude * in case you flip this to {@link ElementsWithoutRelationships#VISIBLE}. * @@ -859,7 +859,7 @@ public DiagramOptions withSkinParam(String name, @Nullable String value) { public static DiagramOptions defaults() { return new DiagramOptions(ALL_TYPES, DependencyDepth.IMMEDIATE, it -> false, it -> true, it -> false, null, __ -> Optional.empty(), it -> it.getDisplayName(), DiagramStyle.C4, - ElementsWithoutRelationships.HIDDEN, new LinkedHashMap<>()); + ElementsWithoutRelationships.VISIBLE, new LinkedHashMap<>()); } /** diff --git a/spring-modulith-events/pom.xml b/spring-modulith-events/pom.xml index 97f35404c..ebef555bd 100644 --- a/spring-modulith-events/pom.xml +++ b/spring-modulith-events/pom.xml @@ -55,11 +55,16 @@ - + + sonatype + + releaseTrain + + diff --git a/spring-modulith-events/spring-modulith-events-amqp/src/main/java/org/springframework/modulith/events/amqp/RabbitEventExternalizerConfiguration.java b/spring-modulith-events/spring-modulith-events-amqp/src/main/java/org/springframework/modulith/events/amqp/RabbitEventExternalizerConfiguration.java index c7abde3b4..1c81032f1 100644 --- a/spring-modulith-events/spring-modulith-events-amqp/src/main/java/org/springframework/modulith/events/amqp/RabbitEventExternalizerConfiguration.java +++ b/spring-modulith-events/spring-modulith-events-amqp/src/main/java/org/springframework/modulith/events/amqp/RabbitEventExternalizerConfiguration.java @@ -68,6 +68,16 @@ class RabbitEventExternalizerConfiguration { private static final Logger logger = LoggerFactory.getLogger(RabbitEventExternalizerConfiguration.class); private static final String PUBLISHER_CONFIRM_TYPE = "spring.rabbitmq.publisher-confirm-type"; + private final EvaluationContext evaluationContext; + + RabbitEventExternalizerConfiguration(BeanFactory factory) { + + var context = new StandardEvaluationContext(); + context.setBeanResolver(new BeanFactoryResolver(factory)); + + this.evaluationContext = context; + } + @Bean @ConditionalOnProperty(name = ExternalizationMode.PROPERTY, havingValue = "module-listener", matchIfMissing = true) EventExternalizerModuleListener rabbitEventExternalizer(EventExternalizationConfiguration configuration, @@ -76,22 +86,22 @@ EventExternalizerModuleListener rabbitEventExternalizer(EventExternalizationConf logger.debug("Registering domain event externalization to RabbitMQ…"); return new EventExternalizerModuleListener(configuration, - createRabbitTransport(configuration, operations, factory)); + createRabbitTransport(configuration, operations), factory); } @AutoConfiguration @ConditionalOnProperty(name = ExternalizationMode.PROPERTY, havingValue = "outbox") - static class RabbitOutboxConfiguration { + class RabbitOutboxConfiguration { private final OutboxEventExternalizer externalizer; RabbitOutboxConfiguration(EventExternalizationConfiguration configuration, RabbitMessageOperations operations, - BeanFactory beanFactory, OutboxEventExternalizerFactory factory, Environment environment) { + OutboxEventExternalizerFactory factory, Environment environment) { Assert.state("correlated".equalsIgnoreCase(environment.getProperty(PUBLISHER_CONFIRM_TYPE)), () -> "RabbitMQ outbox event externalization requires " + PUBLISHER_CONFIRM_TYPE + "=correlated!"); - this.externalizer = factory.forTransport(createConfirmingRabbitTransport(configuration, operations, beanFactory)); + this.externalizer = factory.forTransport(createConfirmingRabbitTransport(configuration, operations)); } @AutoConfiguration @@ -121,28 +131,26 @@ JobRunrExternalizationTransport jobRunrRabbitOutboxExternalizer() { } } - private static EventExternalizationTransport createRabbitTransport( - EventExternalizationConfiguration configuration, RabbitMessageOperations operations, - BeanFactory factory) { + private EventExternalizationTransport createRabbitTransport( + EventExternalizationConfiguration configuration, RabbitMessageOperations operations) { return (payload, target) -> { - send(payload, target, configuration, Collections.emptyMap(), operations, factory); + send(payload, target, configuration, Collections.emptyMap(), operations); return CompletableFuture.completedFuture(null); }; } - private static EventExternalizationTransport createConfirmingRabbitTransport( - EventExternalizationConfiguration configuration, RabbitMessageOperations operations, - BeanFactory factory) { + private EventExternalizationTransport createConfirmingRabbitTransport(EventExternalizationConfiguration configuration, + RabbitMessageOperations operations) { return (payload, target) -> { var correlation = new CorrelationData(); var headers = Map.of(AmqpHeaders.PUBLISH_CONFIRM_CORRELATION, (Object) correlation); - send(payload, target, configuration, headers, operations, factory); + send(payload, target, configuration, headers, operations); return correlation.getFuture().thenAccept(confirm -> { @@ -154,22 +162,13 @@ private static EventExternalizationTransport createConfirmingRabbitTransport( }; } - private static void send(Object payload, RoutingTarget target, EventExternalizationConfiguration configuration, - Map additionalHeaders, RabbitMessageOperations operations, BeanFactory factory) { - - var routing = BrokerRouting.of(target, createContext(factory)); + private void send(Object payload, RoutingTarget target, EventExternalizationConfiguration configuration, + Map additionalHeaders, RabbitMessageOperations operations) { + var routing = BrokerRouting.of(target, evaluationContext); var headers = new HashMap<>(configuration.getHeadersFor(payload)); headers.putAll(additionalHeaders); operations.convertAndSend(routing.getTarget(payload), routing.getKey(payload), payload, additionalHeaders); } - - private static EvaluationContext createContext(BeanFactory factory) { - - var context = new StandardEvaluationContext(); - context.setBeanResolver(new BeanFactoryResolver(factory)); - - return context; - } } diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/config/EventExternalizationAutoConfiguration.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/config/EventExternalizationAutoConfiguration.java index aa7b35e5b..7b1896834 100644 --- a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/config/EventExternalizationAutoConfiguration.java +++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/config/EventExternalizationAutoConfiguration.java @@ -26,7 +26,7 @@ import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationListener; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Role; @@ -89,8 +89,8 @@ static EventExternalizationConfiguration eventExternalizationConfiguration(BeanF @Role(BeanDefinition.ROLE_INFRASTRUCTURE) @ConditionalOnProperty(name = ExternalizationMode.PROPERTY, havingValue = "outbox") static OutboxEventExternalizerFactory outboxEventExternalizerFactory(EventExternalizationConfiguration configuration, - ApplicationEventPublisher publisher) { - return new OutboxEventExternalizerFactory(configuration, publisher); + ApplicationContext context) { + return new OutboxEventExternalizerFactory(configuration, context); } /** diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/config/EventPublicationAutoConfiguration.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/config/EventPublicationAutoConfiguration.java index abf9d6602..49cf3472e 100644 --- a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/config/EventPublicationAutoConfiguration.java +++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/config/EventPublicationAutoConfiguration.java @@ -113,8 +113,9 @@ static DefaultFailedEventPublications failedEventPublications( @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) @ConditionalOnBean(EventPublicationRegistry.class) - static CompletionRegisteringAdvisor completionRegisteringAdvisor(ObjectFactory registry) { - return EventPublicationConfiguration.completionRegisteringAdvisor(registry); + static CompletionRegisteringAdvisor completionRegisteringAdvisor(ObjectFactory registry, + ObjectFactory environment) { + return EventPublicationConfiguration.completionRegisteringAdvisor(registry, environment); } @Bean diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/config/EventPublicationConfiguration.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/config/EventPublicationConfiguration.java index 99db6221a..8c76b02b0 100644 --- a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/config/EventPublicationConfiguration.java +++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/config/EventPublicationConfiguration.java @@ -28,6 +28,7 @@ import org.springframework.modulith.events.core.DefaultAbandonedEventPublications; import org.springframework.modulith.events.core.DefaultEventPublicationRegistry; import org.springframework.modulith.events.core.DefaultFailedEventPublications; +import org.springframework.modulith.events.core.EventListenerMethodMetadata; import org.springframework.modulith.events.core.EventPublicationRegistry; import org.springframework.modulith.events.core.EventPublicationRepository; import org.springframework.modulith.events.support.CompletionRegisteringAdvisor; @@ -81,7 +82,9 @@ static DefaultFailedEventPublications failedEventPublications( @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) - static CompletionRegisteringAdvisor completionRegisteringAdvisor(ObjectFactory registry) { - return new CompletionRegisteringAdvisor(registry::getObject); + static CompletionRegisteringAdvisor completionRegisteringAdvisor(ObjectFactory registry, + ObjectFactory environment) { + return new CompletionRegisteringAdvisor(registry::getObject, + EventListenerMethodMetadata.of(environment::getObject)); } } diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/config/StalenessProperties.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/config/StalenessProperties.java index b9fcfb6df..a4cb75663 100644 --- a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/config/StalenessProperties.java +++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/config/StalenessProperties.java @@ -65,17 +65,18 @@ public class StalenessProperties implements Staleness { @Nullable Duration resubmitted, @Nullable Duration checkInterval) { - this.published = published == null ? Duration.ZERO : published; - this.processing = processing == null ? Duration.ZERO : processing; - this.resubmitted = resubmitted == null ? Duration.ZERO : resubmitted; - this.checkInterval = checkInterval == null ? Duration.ofMinutes(1) : checkInterval; + this.published = published == null ? UNCONFIGURED_DURATION : requirePositive(published, "published"); + this.processing = processing == null ? UNCONFIGURED_DURATION : requirePositive(processing, "processing"); + this.resubmitted = resubmitted == null ? UNCONFIGURED_DURATION : requirePositive(resubmitted, "resubmitted"); + this.checkInterval = checkInterval == null ? Duration.ofMinutes(1) + : requirePositive(checkInterval, "checkInterval"); } boolean monitorStaleness() { - return !published.equals(Duration.ZERO) - || !processing.equals(Duration.ZERO) - || !resubmitted.equals(Duration.ZERO); + return !published.equals(UNCONFIGURED_DURATION) + || !processing.equals(UNCONFIGURED_DURATION) + || !resubmitted.equals(UNCONFIGURED_DURATION); } Duration getCheckInterval() { @@ -95,7 +96,15 @@ public Duration getStaleness(Status status) { case PUBLISHED -> published; case PROCESSING -> processing; case RESUBMITTED -> resubmitted; - default -> Duration.ZERO; + default -> throw new IllegalArgumentException("Unsupported status: " + status); }; } + + private static Duration requirePositive(Duration duration, String name) { + + Assert.isTrue(!duration.isZero() && !duration.isNegative(), + () -> "Staleness property '%s' must be greater than zero but was %s!".formatted(name, duration)); + + return duration; + } } diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/DefaultEventPublicationRegistry.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/DefaultEventPublicationRegistry.java index b3e0bbe72..90dfcf491 100644 --- a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/DefaultEventPublicationRegistry.java +++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/DefaultEventPublicationRegistry.java @@ -409,6 +409,10 @@ private void propagateStateTransition(Object event, PublicationTargetIdentifier private void markFailed(Status status, Staleness staleness) { + if (!staleness.isMonitored(status)) { + return; + } + var duration = staleness.getStaleness(status); var reference = clock.instant().minus(duration); var result = events.findByStatus(status).stream() diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/EventListenerMethodMetadata.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/EventListenerMethodMetadata.java new file mode 100644 index 000000000..d58f0fefe --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/EventListenerMethodMetadata.java @@ -0,0 +1,178 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.modulith.events.core; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.function.Supplier; + +import org.jspecify.annotations.Nullable; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.core.env.Environment; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalApplicationListener; +import org.springframework.transaction.event.TransactionalApplicationListenerMethodAdapter; +import org.springframework.transaction.event.TransactionalEventListener; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.ReflectionUtils; +import org.springframework.util.StringUtils; +import org.springframework.util.function.SingletonSupplier; + +/** + * Metadata about event listener methods, in particular which of them are to be considered by the Event Publication + * Registry, as configured in the {@link Environment}. By default, all listeners (meta-)annotated with + * {@link TransactionalEventListener} and listening for {@link TransactionPhase#AFTER_COMMIT} trigger an entry in + * the registry. + * Configuring an annotation via {@code spring.modulith.events.registry-trigger-annotation} narrows that down to the + * listeners carrying it, which in turn causes all other listeners to be left alone by the registry entirely. + * + * @author Oliver Drotbohm + * @since 2.2, 2.1.2 + */ +public class EventListenerMethodMetadata { + + static final String TRIGGER_ANNOTATION_PROPERTY = "spring.modulith.events.registry-trigger-annotation"; + + private static final Method GET_TARGET_METHOD; + + static { + + GET_TARGET_METHOD = ReflectionUtils + .findMethod(TransactionalApplicationListenerMethodAdapter.class, "getTargetMethod"); + ReflectionUtils.makeAccessible(GET_TARGET_METHOD); + } + + private final Supplier<@Nullable Class> triggerAnnotation; + + private EventListenerMethodMetadata(Supplier<@Nullable Class> triggerAnnotation) { + this.triggerAnnotation = triggerAnnotation; + } + + /** + * Creates a new {@link EventListenerMethodMetadata} for the given {@link Environment}. The latter is only accessed on + * first use and the trigger annotation resolved from it cached. + * + * @param environment must not be {@literal null}. + * @return will never be {@literal null}. + */ + public static EventListenerMethodMetadata of(Supplier environment) { + + Assert.notNull(environment, "Environment must not be null!"); + + return new EventListenerMethodMetadata( + SingletonSupplier.ofNullable(() -> findTriggerAnnotation(environment.get()))); + } + + /** + * Creates a new {@link EventListenerMethodMetadata} considering all transactional event listener methods, i.e. the + * arrangement in place if no trigger annotation is configured. + * + * @return will never be {@literal null}. + */ + public static EventListenerMethodMetadata all() { + return new EventListenerMethodMetadata(() -> null); + } + + /** + * Returns whether the given {@link Method} is supposed to trigger an entry in the Event Publication Registry, i.e. + * whether it is an {@link TransactionPhase#AFTER_COMMIT} {@link TransactionalEventListener} carrying the configured + * trigger annotation, if any. + * + * @param method must not be {@literal null}. + */ + public boolean triggersRegistry(Method method) { + + Assert.notNull(method, "Method must not be null!"); + + var annotation = AnnotatedElementUtils.findMergedAnnotation(method, TransactionalEventListener.class); + + return annotation != null + && annotation.phase().equals(TransactionPhase.AFTER_COMMIT) + && hasTriggerAnnotation(method); + } + + /** + * Returns whether the given {@link TransactionalApplicationListener} is supposed to trigger an entry in the Event + * Publication Registry, i.e. whether it listens for {@link TransactionPhase#AFTER_COMMIT} and is backed by a method + * carrying the configured trigger annotation, if any. Listeners not backed by an event listener method are excluded + * as soon as a trigger annotation is configured, as their qualification cannot be established. + * + * @param listener must not be {@literal null}. + */ + boolean triggersRegistry(TransactionalApplicationListener listener) { + + Assert.notNull(listener, "TransactionalApplicationListener must not be null!"); + + if (!listener.getTransactionPhase().equals(TransactionPhase.AFTER_COMMIT)) { + return false; + } + + if (triggerAnnotation.get() == null) { + return true; + } + + if (!(listener instanceof TransactionalApplicationListenerMethodAdapter adapter)) { + return false; + } + + var method = (Method) ReflectionUtils.invokeMethod(GET_TARGET_METHOD, adapter); + + return method != null && hasTriggerAnnotation(method); + } + + /** + * Returns whether the given {@link Method} carries the configured trigger annotation, if any. + * + * @param method must not be {@literal null}. + */ + private boolean hasTriggerAnnotation(Method method) { + + var annotationType = triggerAnnotation.get(); + + return annotationType == null || AnnotatedElementUtils.hasAnnotation(method, annotationType); + } + + /** + * Returns the annotation type configured via {@value #TRIGGER_ANNOTATION_PROPERTY} or {@literal null} if none is + * configured. + * + * @param environment must not be {@literal null}. + */ + @SuppressWarnings("unchecked") + private static @Nullable Class findTriggerAnnotation(Environment environment) { + + var annotationName = environment.getProperty(TRIGGER_ANNOTATION_PROPERTY); + + if (!StringUtils.hasText(annotationName)) { + return null; + } + + try { + + var annotationType = ClassUtils.forName(annotationName, EventListenerMethodMetadata.class.getClassLoader()); + + if (!annotationType.isAnnotation()) { + throw new IllegalStateException("Configured type is not an annotation!"); + } + + return (Class) annotationType; + + } catch (ClassNotFoundException o_O) { + throw new IllegalStateException(o_O); + } + } +} diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/Staleness.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/Staleness.java index cbf94c0f1..21f8202c7 100644 --- a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/Staleness.java +++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/Staleness.java @@ -28,12 +28,54 @@ */ public interface Staleness { + /** + * The {@link Duration} to be used to indicate that no staleness has been configured for a particular {@link Status}, + * i.e. that publications in that state are not supposed to be checked for staleness. + * + * @since 2.0.9, 2.1.2, 2.2 + */ + Duration UNCONFIGURED_DURATION = Duration.ZERO; + + /** + * Returns whether {@link org.springframework.modulith.events.EventPublication}s with the given {@link Status} are + * supposed to be checked for staleness at all. Callers are expected to inspect this before calling + * {@link #getStaleness(Status)}. Returns {@literal false}, and does not fail, for {@link Status}es that cannot become + * stale. + * + * @param status must not be {@literal null}. + * @return whether staleness needs to be handled for the given {@link Status}. + * @since 2.0.9, 2.1.2, 2.2 + */ + default boolean isMonitored(Status status) { + return isStaleable(status) && !UNCONFIGURED_DURATION.equals(getStaleness(status)); + } + + /** + * Returns whether {@link org.springframework.modulith.events.EventPublication}s with the given {@link Status} can + * become stale at all. + * + * @param status must not be {@literal null}. + * @return whether the given {@link Status} can become stale. + * @since 2.0.9, 2.1.2, 2.2 + */ + default boolean isStaleable(Status status) { + + return switch (status) { + case PUBLISHED, PROCESSING, RESUBMITTED -> true; + default -> false; + }; + } + /** * Returns the {@link Duration} after which {@link org.springframework.modulith.events.EventPublication}s with a - * certain {@link Status} are considered stale. + * certain {@link Status} are considered stale. Guaranteed to be positive if {@link #isMonitored(Status)} returned + * {@literal true} before. Rejects {@link Status}es not considered {@link #isStaleable(Status) staleable}. * * @param status must not be {@literal null}. * @return will never be {@literal null}. + * @throws IllegalArgumentException in case the given {@link Status} cannot become stale. + * @see #isMonitored(Status) + * @see #isStaleable(Status) */ Duration getStaleness(Status status); } diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/TransactionalEventListeners.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/TransactionalEventListeners.java index 39ea6c152..3be55f29c 100644 --- a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/TransactionalEventListeners.java +++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/core/TransactionalEventListeners.java @@ -15,8 +15,6 @@ */ package org.springframework.modulith.events.core; -import java.lang.annotation.Annotation; -import java.lang.reflect.Method; import java.util.Collection; import java.util.List; import java.util.function.Consumer; @@ -27,16 +25,10 @@ import org.springframework.context.ApplicationEvent; import org.springframework.context.ApplicationListener; -import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.core.annotation.AnnotationAwareOrderComparator; import org.springframework.core.env.Environment; -import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalApplicationListener; -import org.springframework.transaction.event.TransactionalApplicationListenerMethodAdapter; import org.springframework.util.Assert; -import org.springframework.util.ClassUtils; -import org.springframework.util.ReflectionUtils; -import org.springframework.util.StringUtils; /** * First-class collection to work with transactional event listeners, i.e. {@link ApplicationListener} instances that @@ -50,17 +42,6 @@ */ public class TransactionalEventListeners { - static final String TRIGGER_ANNOTATION_PROPERTY = "spring.modulith.events.registry-trigger-annotation"; - - private static final Method GET_TARGET_METHOD; - - static { - - GET_TARGET_METHOD = ReflectionUtils - .findMethod(TransactionalApplicationListenerMethodAdapter.class, "getTargetMethod"); - ReflectionUtils.makeAccessible(GET_TARGET_METHOD); - } - private final List> listeners; /** @@ -68,6 +49,7 @@ public class TransactionalEventListeners { * {@link TransactionalApplicationListener}. * * @param listeners must not be {@literal null}. + * @param environment must not be {@literal null}. */ @SuppressWarnings({ "rawtypes", "unchecked" }) public TransactionalEventListeners(Collection> listeners, @@ -75,11 +57,12 @@ public TransactionalEventListeners(Collection> listeners, Assert.notNull(listeners, "ApplicationListeners must not be null!"); + var metadata = EventListenerMethodMetadata.of(environment); + this.listeners = (List) listeners.stream() .filter(TransactionalApplicationListener.class::isInstance) .map(TransactionalApplicationListener.class::cast) - .filter(it -> it.getTransactionPhase().equals(TransactionPhase.AFTER_COMMIT)) - .filter(byAnnotationFilter(environment)) + .filter(metadata::triggersRegistry) .sorted(AnnotationAwareOrderComparator.INSTANCE) .toList(); } @@ -137,45 +120,4 @@ Stream> stream() { return listeners.stream(); } - /** - * Returns a {@link Predicate} filtering the listeners by the trigger annotation configured in - * {@code spring.modulith.events.annotation}. - * - * @param environment must not be {@literal null}. - * @return will never be {@literal null}. - * @since 2.1 - */ - @SuppressWarnings({ "rawtypes", "unchecked" }) - private static Predicate byAnnotationFilter( - Supplier environment) { - - return listener -> { - - var annotationName = environment.get().getProperty(TRIGGER_ANNOTATION_PROPERTY); - - if (!StringUtils.hasText(annotationName)) { - return true; - } - - try { - - var annotationType = ClassUtils.forName(annotationName, TransactionalEventListeners.class.getClassLoader()); - - if (!annotationType.isAnnotation()) { - throw new IllegalStateException("Configured type is not an annotation!"); - } - - if (!(listener instanceof TransactionalApplicationListenerMethodAdapter)) { - return false; - } - - var method = (Method) ReflectionUtils.invokeMethod(GET_TARGET_METHOD, listener); - - return AnnotatedElementUtils.hasAnnotation(method, (Class) annotationType); - - } catch (ClassNotFoundException o_O) { - throw new IllegalStateException(o_O); - } - }; - } } diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/CompletionRegisteringAdvisor.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/CompletionRegisteringAdvisor.java index af29a313d..d16746251 100644 --- a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/CompletionRegisteringAdvisor.java +++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/CompletionRegisteringAdvisor.java @@ -32,10 +32,9 @@ import org.springframework.aop.support.StaticMethodMatcher; import org.springframework.aop.support.annotation.AnnotationMatchingPointcut; import org.springframework.core.Ordered; -import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.modulith.events.core.EventListenerMethodMetadata; import org.springframework.modulith.events.core.EventPublicationRegistry; import org.springframework.modulith.events.core.PublicationTargetIdentifier; -import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalApplicationListenerMethodAdapter; import org.springframework.transaction.event.TransactionalEventListener; import org.springframework.util.Assert; @@ -55,13 +54,33 @@ public class CompletionRegisteringAdvisor extends AbstractPointcutAdvisor { private final Advice advice; /** - * Creates a new {@link CompletionRegisteringAdvisor} for the given {@link EventPublicationRegistry}. + * Creates a new {@link CompletionRegisteringAdvisor} for the given {@link EventPublicationRegistry}, decorating all + * {@link TransactionalEventListener} annotated methods, no matter whether they are selected via + * {@code spring.modulith.events.registry-trigger-annotation}. * * @param registry must not be {@literal null}. + * @deprecated since 2.2, 2.1.2, for removal in 2.3. Use + * {@link #CompletionRegisteringAdvisor(Supplier, EventListenerMethodMetadata)} instead to honor the + * configured registry trigger annotation. */ + @Deprecated(since = "2.2, 2.1.2", forRemoval = true) public CompletionRegisteringAdvisor(Supplier registry) { + this(registry, EventListenerMethodMetadata.all()); + } + + /** + * Creates a new {@link CompletionRegisteringAdvisor} for the given {@link EventPublicationRegistry}, only decorating + * methods that actually trigger an entry in the former according to the given {@link EventListenerMethodMetadata}. + * + * @param registry must not be {@literal null}. + * @param metadata must not be {@literal null}. + * @since 2.2, 2.1.2 + */ + public CompletionRegisteringAdvisor(Supplier registry, + EventListenerMethodMetadata metadata) { Assert.notNull(registry, "EventPublicationRegistry must not be null!"); + Assert.notNull(metadata, "EventListenerMethodMetadata must not be null!"); this.pointcut = new AnnotationMatchingPointcut(null, TransactionalEventListener.class, true) { @@ -71,7 +90,7 @@ public CompletionRegisteringAdvisor(Supplier registry) */ @Override public MethodMatcher getMethodMatcher() { - return new CommitListenerMethodMatcher(super.getMethodMatcher()); + return new CommitListenerMethodMatcher(super.getMethodMatcher(), metadata); } }; @@ -103,14 +122,19 @@ public Advice getAdvice() { private static class CommitListenerMethodMatcher extends StaticMethodMatcher { private final MethodMatcher delegate; + private final EventListenerMethodMetadata metadata; /** - * Creates a new {@link CommitListenerMethodMatcher} with the given delegate {@link MethodMatcher}. + * Creates a new {@link CommitListenerMethodMatcher} with the given delegate {@link MethodMatcher} and + * {@link EventListenerMethodMetadata}. * * @param delegate must not be {@literal null}. + * @param metadata must not be {@literal null}. */ - public CommitListenerMethodMatcher(MethodMatcher delegate) { + public CommitListenerMethodMatcher(MethodMatcher delegate, EventListenerMethodMetadata metadata) { + this.delegate = delegate; + this.metadata = metadata; } /* @@ -119,14 +143,7 @@ public CommitListenerMethodMatcher(MethodMatcher delegate) { */ @Override public boolean matches(Method method, Class targetClass) { - - if (!delegate.matches(method, targetClass)) { - return false; - } - - var annotation = AnnotatedElementUtils.findMergedAnnotation(method, TransactionalEventListener.class); - - return annotation != null && annotation.phase().equals(TransactionPhase.AFTER_COMMIT); + return delegate.matches(method, targetClass) && metadata.triggersRegistry(method); } } diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/EventExternalizerModuleListener.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/EventExternalizerModuleListener.java index 8116bec3f..86909c791 100644 --- a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/EventExternalizerModuleListener.java +++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/EventExternalizerModuleListener.java @@ -17,6 +17,7 @@ import java.util.concurrent.CompletableFuture; +import org.springframework.beans.factory.BeanFactory; import org.springframework.modulith.events.ApplicationModuleListener; import org.springframework.modulith.events.EventExternalizationConfiguration; import org.springframework.modulith.events.core.ConditionalEventListener; @@ -47,7 +48,11 @@ public class EventExternalizerModuleListener extends TransportAwareEventExternal * * @param configuration must not be {@literal null}. * @param transport must not be {@literal null}. + * @deprecated since 2.2, 2.1.2, for removal in 2.3. Use + * {@link #EventExternalizerModuleListener(EventExternalizationConfiguration, EventExternalizationTransport, BeanFactory)} + * instead. */ + @Deprecated(since = "2.2, 2.1.2", forRemoval = true) public EventExternalizerModuleListener(EventExternalizationConfiguration configuration, EventExternalizationTransport transport) { @@ -58,6 +63,26 @@ public EventExternalizerModuleListener(EventExternalizationConfiguration configu this.configuration = configuration; } + /** + * Creates a new {@link EventExternalizerModuleListener} for the given {@link EventExternalizationConfiguration}, + * {@link EventExternalizationTransport} and {@link BeanFactory} to resolve routing target and key expressions (which + * may refer to beans) against the original event. + * + * @param configuration must not be {@literal null}. + * @param transport must not be {@literal null}. + * @param beanFactory must not be {@literal null}. + * @since 2.2, 2.1.2 + */ + public EventExternalizerModuleListener(EventExternalizationConfiguration configuration, + EventExternalizationTransport transport, BeanFactory beanFactory) { + + super(configuration, transport, beanFactory); + + Assert.notNull(transport, "EventExternalizationTransport must not be null!"); + + this.configuration = configuration; + } + /* * (non-Javadoc) * @see org.springframework.modulith.events.ConditionalEventListener#supports(java.lang.Object) diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/EventExternalizerSupport.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/EventExternalizerSupport.java index 49624c06d..31ba66101 100644 --- a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/EventExternalizerSupport.java +++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/EventExternalizerSupport.java @@ -20,6 +20,10 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.context.expression.BeanFactoryResolver; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.spel.support.StandardEvaluationContext; import org.springframework.modulith.events.EventExternalizationConfiguration; import org.springframework.modulith.events.EventExternalized; import org.springframework.modulith.events.RoutingTarget; @@ -38,18 +42,50 @@ abstract class EventExternalizerSupport { private static final Logger logger = LoggerFactory.getLogger(EventExternalizerSupport.class); private final EventExternalizationConfiguration configuration; + private final EvaluationContext context; private final Semaphore semaphore = new Semaphore(1); /** - * Creates a new {@link EventExternalizerSupport} for the given {@link EventExternalizationConfiguration}. + * Creates a new {@link EventExternalizerSupport} for the given {@link EventExternalizationConfiguration}, not + * resolving bean references in routing target and key expressions. * * @param configuration must not be {@literal null}. + * @deprecated since 2.2, 2.1.2, for removal in 2.3. Use + * {@link #EventExternalizerSupport(EventExternalizationConfiguration, BeanFactory)} instead. */ + @Deprecated(since = "2.2, 2.1.2", forRemoval = true) protected EventExternalizerSupport(EventExternalizationConfiguration configuration) { + this(configuration, new StandardEvaluationContext()); + } + + /** + * Creates a new {@link EventExternalizerSupport} for the given {@link EventExternalizationConfiguration}, resolving + * routing target and key expressions (which may refer to beans) against the original event using an + * {@link EvaluationContext} backed by the given {@link BeanFactory}. + * + * @param configuration must not be {@literal null}. + * @param beanFactory must not be {@literal null}. + * @since 2.2, 2.1.2 + */ + protected EventExternalizerSupport(EventExternalizationConfiguration configuration, BeanFactory beanFactory) { + + Assert.notNull(configuration, "EventExternalizationConfiguration must not be null!"); + Assert.notNull(beanFactory, "BeanFactory must not be null!"); + + var context = new StandardEvaluationContext(); + context.setBeanResolver(new BeanFactoryResolver(beanFactory)); + + this.configuration = configuration; + this.context = context; + } + + private EventExternalizerSupport(EventExternalizationConfiguration configuration, EvaluationContext context) { Assert.notNull(configuration, "EventExternalizationConfiguration must not be null!"); + Assert.notNull(context, "EvaluationContext must not be null!"); this.configuration = configuration; + this.context = context; } /** @@ -67,17 +103,18 @@ public CompletableFuture externalize(Object event) { } var target = configuration.determineTarget(event); + var resolved = resolve(target, event); var mapped = configuration.map(event); if (logger.isTraceEnabled()) { - logger.trace("Externalizing event of type {} to {}, payload: {}).", event.getClass(), target, mapped); + logger.trace("Externalizing event of type {} to {}, payload: {}).", event.getClass(), resolved, mapped); } else if (logger.isDebugEnabled()) { - logger.debug("Externalizing event of type {} to {}.", event.getClass(), target); + logger.debug("Externalizing event of type {} to {}.", event.getClass(), resolved); } return configuration.serializeExternalization() - ? doExternalizeSerialized(event, mapped, target) - : doExternalize(event, mapped, target); + ? doExternalizeSerialized(event, mapped, resolved) + : doExternalize(event, mapped, resolved); } /** @@ -110,4 +147,24 @@ private CompletableFuture doExternalize(Object event, Object mapped, RoutingT return externalize(mapped, target) .thenApply(it -> new EventExternalized<>(event, mapped, target, it)); } + + /** + * Resolves dynamic target and key expressions declared on the given {@link RoutingTarget} against the given + * (original, unmapped) event, so that a mapping applied downstream cannot affect routing. + * + * @param target must not be {@literal null}. + * @param event must not be {@literal null}. + * @return will never be {@literal null}. + */ + private RoutingTarget resolve(RoutingTarget target, Object event) { + + if (!target.hasExpression()) { + return target; + } + + var routing = BrokerRouting.of(target, context); + var resolved = RoutingTarget.forTarget(routing.getTarget(event)); + + return target.getKey() == null ? resolved.withoutKey() : resolved.andKey(routing.getKey(event)); + } } diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/OutboxEventExternalizer.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/OutboxEventExternalizer.java index 45147d973..ebb4354b6 100644 --- a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/OutboxEventExternalizer.java +++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/OutboxEventExternalizer.java @@ -18,6 +18,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; +import org.springframework.beans.factory.BeanFactory; import org.springframework.context.ApplicationEventPublisher; import org.springframework.modulith.events.EventExternalizationConfiguration; @@ -43,7 +44,11 @@ public class OutboxEventExternalizer extends TransportAwareEventExternalizer { * * @param configuration must not be {@literal null}. * @param publisher must not be {@literal null}. + * @deprecated since 2.2, 2.1.2, for removal in 2.3. Use + * {@link #OutboxEventExternalizer(EventExternalizationConfiguration, ApplicationEventPublisher, EventExternalizationTransport, BeanFactory)} + * instead. */ + @Deprecated(since = "2.2, 2.1.2", forRemoval = true) public OutboxEventExternalizer(EventExternalizationConfiguration configuration, ApplicationEventPublisher publisher, EventExternalizationTransport transport) { @@ -52,6 +57,24 @@ public OutboxEventExternalizer(EventExternalizationConfiguration configuration, this.events = publisher; } + /** + * Creates a new {@link OutboxEventExternalizer} for the given {@link EventExternalizationConfiguration}, transport + * function and {@link BeanFactory} to resolve routing target and key expressions (which may refer to beans) against + * the original event. + * + * @param configuration must not be {@literal null}. + * @param publisher must not be {@literal null}. + * @param beanFactory must not be {@literal null}. + * @since 2.2, 2.1.2 + */ + public OutboxEventExternalizer(EventExternalizationConfiguration configuration, ApplicationEventPublisher publisher, + EventExternalizationTransport transport, BeanFactory beanFactory) { + + super(configuration, transport, beanFactory); + + this.events = publisher; + } + /* * (non-Javadoc) * @see org.springframework.modulith.events.support.EventExternalizerSupport#externalize(java.lang.Object) diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/OutboxEventExternalizerFactory.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/OutboxEventExternalizerFactory.java index cf427705b..40e224186 100644 --- a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/OutboxEventExternalizerFactory.java +++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/OutboxEventExternalizerFactory.java @@ -15,6 +15,9 @@ */ package org.springframework.modulith.events.support; +import org.jspecify.annotations.Nullable; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationEventPublisher; import org.springframework.modulith.events.EventExternalizationConfiguration; import org.springframework.util.Assert; @@ -31,22 +34,45 @@ public class OutboxEventExternalizerFactory { private final EventExternalizationConfiguration configuration; private final ApplicationEventPublisher publisher; + private final @Nullable BeanFactory beanFactory; /** * Creates a new {@link OutboxEventExternalizerFactory} for the given {@link EventExternalizationConfiguration} and - * {@link ApplicationEventPublisher}. + * {@link ApplicationEventPublisher}, not resolving bean references in routing target and key expressions. * * @param configuration must not be {@literal null}. * @param publisher must not be {@literal null}. + * @deprecated since 2.2, 2.1.2, for removal in 2.3. Use + * {@link #OutboxEventExternalizerFactory(EventExternalizationConfiguration, ApplicationContext)} instead. */ + @Deprecated(since = "2.2, 2.1.2", forRemoval = true) public OutboxEventExternalizerFactory(EventExternalizationConfiguration configuration, ApplicationEventPublisher publisher) { + this(configuration, publisher, null); + } + + /** + * Creates a new {@link OutboxEventExternalizerFactory} for the given {@link EventExternalizationConfiguration} and + * {@link ApplicationContext}, resolving routing target and key expressions (which may refer to beans) against the + * original event. + * + * @param configuration must not be {@literal null}. + * @param context must not be {@literal null}. + * @since 2.2, 2.1.2 + */ + public OutboxEventExternalizerFactory(EventExternalizationConfiguration configuration, ApplicationContext context) { + this(configuration, context, context); + } + + private OutboxEventExternalizerFactory(EventExternalizationConfiguration configuration, + ApplicationEventPublisher publisher, @Nullable BeanFactory beanFactory) { Assert.notNull(configuration, "EventExternalizationConfiguration must not be null!"); Assert.notNull(publisher, "ApplicationEventPublisher must not be null!"); this.configuration = configuration; this.publisher = publisher; + this.beanFactory = beanFactory; } /** @@ -59,6 +85,8 @@ public OutboxEventExternalizer forTransport(EventExternalizationTransport transp Assert.notNull(transport, "EventExternalizationTransport must not be null!"); - return new OutboxEventExternalizer(configuration, publisher, transport); + return beanFactory != null + ? new OutboxEventExternalizer(configuration, publisher, transport, beanFactory) + : new OutboxEventExternalizer(configuration, publisher, transport); } } diff --git a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/TransportAwareEventExternalizer.java b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/TransportAwareEventExternalizer.java index 2835f94e6..5865ffc8e 100644 --- a/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/TransportAwareEventExternalizer.java +++ b/spring-modulith-events/spring-modulith-events-core/src/main/java/org/springframework/modulith/events/support/TransportAwareEventExternalizer.java @@ -17,6 +17,7 @@ import java.util.concurrent.CompletableFuture; +import org.springframework.beans.factory.BeanFactory; import org.springframework.modulith.events.EventExternalizationConfiguration; import org.springframework.modulith.events.RoutingTarget; import org.springframework.util.Assert; @@ -35,11 +36,16 @@ abstract class TransportAwareEventExternalizer extends EventExternalizerSupport /** * Creates a new {@link TransportAwareEventExternalizer} for the given {@link EventExternalizationConfiguration} and - * {@link EventExternalizationTransport} implementing the actual externalization. + * {@link EventExternalizationTransport} implementing the actual externalization, not resolving bean references in + * routing target and key expressions. * * @param configuration must not be {@literal null}. * @param transport must not be {@literal null}. + * @deprecated since 2.2, 2.1.2, for removal in 2.3. Use + * {@link #TransportAwareEventExternalizer(EventExternalizationConfiguration, EventExternalizationTransport, BeanFactory)} + * instead. */ + @Deprecated(since = "2.2, 2.1.2", forRemoval = true) public TransportAwareEventExternalizer(EventExternalizationConfiguration configuration, EventExternalizationTransport transport) { @@ -50,6 +56,26 @@ public TransportAwareEventExternalizer(EventExternalizationConfiguration configu this.transport = transport; } + /** + * Creates a new {@link TransportAwareEventExternalizer} for the given {@link EventExternalizationConfiguration}, + * {@link EventExternalizationTransport} implementing the actual externalization and {@link BeanFactory} to resolve + * routing target and key expressions (which may refer to beans) against the original event. + * + * @param configuration must not be {@literal null}. + * @param transport must not be {@literal null}. + * @param beanFactory must not be {@literal null}. + * @since 2.2, 2.1.2 + */ + public TransportAwareEventExternalizer(EventExternalizationConfiguration configuration, + EventExternalizationTransport transport, BeanFactory beanFactory) { + + super(configuration, beanFactory); + + Assert.notNull(transport, "EventExternalizationTransport must not be null!"); + + this.transport = transport; + } + /* * (non-Javadoc) * @see org.springframework.modulith.events.support.EventExternalizerSupport#externalize(org.springframework.modulith.events.RoutingTarget, java.lang.Object) diff --git a/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/config/StalenessPropertiesUnitTests.java b/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/config/StalenessPropertiesUnitTests.java new file mode 100644 index 000000000..3488620a4 --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/config/StalenessPropertiesUnitTests.java @@ -0,0 +1,119 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.modulith.events.config; + +import static org.assertj.core.api.Assertions.*; +import static org.springframework.modulith.events.EventPublication.Status.*; + +import java.time.Duration; +import java.util.function.Supplier; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.NamedExecutable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; + +/** + * Unit tests for {@link StalenessProperties}. + * + * @author Oliver Drotbohm + */ +class StalenessPropertiesUnitTests { + + @TestFactory // GH-1917 + Stream rejectsZeroAndNegativeDurations() { + + var negative = Duration.ofSeconds(-1); + + var tests = Stream.of( + new $("published", Duration.ZERO), + new $("published", negative), + new $("processing", Duration.ZERO), + new $("processing", negative), + new $("resubmitted", Duration.ZERO), + new $("resubmitted", negative), + new $("checkInterval", Duration.ZERO), + new $("checkInterval", negative)); + + return DynamicTest.stream(tests); + } + + @Test // GH-1917 + void acceptsPositiveAndUnsetDurations() { + + var properties = new StalenessProperties(null, null, Duration.ofMinutes(5), null); + + assertThat(properties.monitorStaleness()).isTrue(); + assertThat(properties.getStaleness(PROCESSING)).isEqualTo(Duration.ZERO); + } + + @Test // GH-1917 + void onlyMonitorsStatusesWithConfiguredStaleness() { + + var properties = new StalenessProperties(null, null, Duration.ofMinutes(5), null); + + assertThat(properties.isMonitored(RESUBMITTED)).isTrue(); + assertThat(properties.isMonitored(PUBLISHED)).isFalse(); + assertThat(properties.isMonitored(PROCESSING)).isFalse(); + } + + @Test // GH-1917 + void doesNotMonitorStatusesThatCannotBecomeStale() { + + var properties = new StalenessProperties(Duration.ofMinutes(1), Duration.ofMinutes(2), Duration.ofMinutes(3), + null); + + assertThat(properties.isMonitored(COMPLETED)).isFalse(); + assertThat(properties.isMonitored(FAILED)).isFalse(); + } + + @Test // GH-1917 + void rejectsStalenessLookupForStatusesThatCannotBecomeStale() { + + var properties = new StalenessProperties(Duration.ofMinutes(1), Duration.ofMinutes(2), Duration.ofMinutes(3), + null); + + assertThatIllegalArgumentException().isThrownBy(() -> properties.getStaleness(COMPLETED)); + assertThatIllegalArgumentException().isThrownBy(() -> properties.getStaleness(FAILED)); + } + + record $(String property, Duration value, Supplier factory) implements NamedExecutable { + + $(String property, Duration value) { + this(property, value, switch (property) { + case "published" -> () -> new StalenessProperties(value, null, null, null); + case "processing" -> () -> new StalenessProperties(null, value, null, null); + case "resubmitted" -> () -> new StalenessProperties(null, null, value, null); + case "checkInterval" -> () -> new StalenessProperties(null, null, null, value); + default -> throw new IllegalArgumentException("Unknown property " + property); + }); + } + + @Override + public final String toString() { + return "Rejects %s for %s".formatted(value, property); + } + + @Override + public void execute() { + + assertThatIllegalArgumentException() + .isThrownBy(factory::get) + .withMessageContaining(property); + } + } +} diff --git a/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/core/DefaultEventPublicationRegistryUnitTests.java b/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/core/DefaultEventPublicationRegistryUnitTests.java index 2b4cfd026..7712ab8b1 100644 --- a/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/core/DefaultEventPublicationRegistryUnitTests.java +++ b/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/core/DefaultEventPublicationRegistryUnitTests.java @@ -241,6 +241,25 @@ void doesNotConsiderRecentlyResubmittedPublicationStaleBasedOnOriginalPublicatio verify(repository, never()).markFailed(any()); } + @Test // GH-1917 + void doesNotMarkPublicationsFailedForStatusWithZeroStaleness() { + + var now = Instant.now(); + + var publication = mock(TargetEventPublication.class, CALLS_REAL_METHODS); + lenient().when(publication.getPublicationDate()).thenReturn(now.minusSeconds(1)); + lenient().when(publication.getLastResubmissionDate()).thenReturn(null); + + lenient().when(repository.findByStatus(Status.PUBLISHED)).thenReturn(List.of(publication)); + lenient().when(repository.findByStatus(Status.PROCESSING)).thenReturn(List.of(publication)); + when(repository.findByStatus(Status.RESUBMITTED)).thenReturn(Collections.emptyList()); + + createRegistry(now).markStalePublicationsFailed( + status -> status == Status.RESUBMITTED ? Duration.ofMinutes(5) : Duration.ZERO); + + verify(repository, never()).markFailed(any()); + } + private DefaultEventPublicationRegistry createRegistry(Instant instant) { var clock = Clock.fixed(instant, ZoneId.systemDefault()); diff --git a/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/core/EventListenerMethodMetadataUnitTests.java b/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/core/EventListenerMethodMetadataUnitTests.java new file mode 100644 index 000000000..c3567183d --- /dev/null +++ b/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/core/EventListenerMethodMetadataUnitTests.java @@ -0,0 +1,115 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.modulith.events.core; + +import static org.assertj.core.api.Assertions.*; +import static org.springframework.modulith.events.core.EventListenerMethodMetadata.*; + +import java.lang.reflect.Method; + +import org.junit.jupiter.api.Test; +import org.springframework.context.event.EventListener; +import org.springframework.mock.env.MockEnvironment; +import org.springframework.modulith.events.ApplicationModuleListener; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +import org.springframework.util.ReflectionUtils; + +/** + * Unit tests for {@link EventListenerMethodMetadata}. + * + * @author Oliver Drotbohm + */ +class EventListenerMethodMetadataUnitTests { + + MockEnvironment environment = new MockEnvironment(); + + @Test // GH-1630 + void rejectsNotLoadableTriggerAnnotation() { + + environment.setProperty(TRIGGER_ANNOTATION_PROPERTY, "some.non.loadable.Type"); + + var metadata = EventListenerMethodMetadata.of(() -> environment); + + assertThatIllegalStateException().isThrownBy(() -> metadata.triggersRegistry(method("plain"))); + } + + @Test // GH-1630 + void rejectsNonAnnotationTypeForTriggerAnnotation() { + + environment.setProperty(TRIGGER_ANNOTATION_PROPERTY, "java.lang.String"); + + var metadata = EventListenerMethodMetadata.of(() -> environment); + + assertThatIllegalStateException().isThrownBy(() -> metadata.triggersRegistry(method("plain"))); + } + + @Test // GH-1903 + void considersAllListenerMethodsByDefault() { + + var metadata = EventListenerMethodMetadata.of(() -> environment); + + assertThat(metadata.triggersRegistry(method("plain"))).isTrue(); + assertThat(metadata.triggersRegistry(method("moduleListener"))).isTrue(); + } + + @Test // GH-1903 + void considersTriggerAnnotation() { + + environment.setProperty(TRIGGER_ANNOTATION_PROPERTY, ApplicationModuleListener.class.getName()); + + var metadata = EventListenerMethodMetadata.of(() -> environment); + + assertThat(metadata.triggersRegistry(method("plain"))).isFalse(); + assertThat(metadata.triggersRegistry(method("moduleListener"))).isTrue(); + } + + @Test // GH-1903 + void onlyConsidersAfterCommitListenerMethods() { + + var metadata = EventListenerMethodMetadata.all(); + + assertThat(metadata.triggersRegistry(method("afterRollback"))).isFalse(); + assertThat(metadata.triggersRegistry(method("plainEventListener"))).isFalse(); + assertThat(metadata.triggersRegistry(method("nonListener"))).isFalse(); + } + + private static Method method(String name) { + + var method = ReflectionUtils.findMethod(SampleListener.class, name, Object.class); + + assertThat(method).isNotNull(); + + return method; + } + + static class SampleListener { + + @TransactionalEventListener + void plain(Object event) {} + + @ApplicationModuleListener + void moduleListener(Object event) {} + + @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK) + void afterRollback(Object event) {} + + @EventListener + void plainEventListener(Object event) {} + + void nonListener(Object event) {} + } +} diff --git a/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/core/TransactionalEventListenersUnitTests.java b/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/core/TransactionalEventListenersUnitTests.java index 103957b6d..d60484fde 100644 --- a/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/core/TransactionalEventListenersUnitTests.java +++ b/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/core/TransactionalEventListenersUnitTests.java @@ -16,7 +16,7 @@ package org.springframework.modulith.events.core; import static org.assertj.core.api.Assertions.*; -import static org.springframework.modulith.events.core.TransactionalEventListeners.*; +import static org.springframework.modulith.events.core.EventListenerMethodMetadata.*; import java.util.List; @@ -69,32 +69,6 @@ void considersTriggerAnnotation() { assertThat(listeners.stream()).containsExactly(second); } - @Test // GH-1630 - void rejectsNotLoadableTriggerAnnotation() { - - var environment = new MockEnvironment(); - environment.setProperty(TRIGGER_ANNOTATION_PROPERTY, "some.non.loadable.Type"); - - var second = getAdapter(ModuleListener.class, "on", SampleEvent.class); - - assertThatIllegalStateException().isThrownBy(() -> { - new TransactionalEventListeners(List.of(second), () -> environment); - }); - } - - @Test // GH-1630 - void rejectsNonAnnotationTypeForTriggerAnnotation() { - - var environment = new MockEnvironment(); - environment.setProperty(TRIGGER_ANNOTATION_PROPERTY, "java.lang.String"); - - var second = getAdapter(ModuleListener.class, "on", SampleEvent.class); - - assertThatIllegalStateException().isThrownBy(() -> { - new TransactionalEventListeners(List.of(second), () -> environment); - }); - } - private static TransactionalApplicationListenerMethodAdapter getAdapter(Class type, String methodName, Class parameter) { diff --git a/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/support/CompletionRegisteringAdvisorIntegrationTests.java b/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/support/CompletionRegisteringAdvisorIntegrationTests.java index 0ad28bd38..655fa501f 100644 --- a/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/support/CompletionRegisteringAdvisorIntegrationTests.java +++ b/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/support/CompletionRegisteringAdvisorIntegrationTests.java @@ -27,6 +27,8 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Role; +import org.springframework.core.env.Environment; +import org.springframework.modulith.events.core.EventListenerMethodMetadata; import org.springframework.modulith.events.core.EventPublicationRegistry; import org.springframework.modulith.events.support.CompletionRegisteringAdvisor.CompletionRegisteringMethodInterceptor; import org.springframework.scheduling.annotation.AnnotationAsyncExecutionInterceptor; @@ -64,11 +66,12 @@ PlatformTransactionManager transactionManager() { @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) - static CompletionRegisteringAdvisor completionRegisteringAdvisor() { + static CompletionRegisteringAdvisor completionRegisteringAdvisor(Environment environment) { var publicationRegistry = mock(EventPublicationRegistry.class); - return new CompletionRegisteringAdvisor(() -> publicationRegistry); + return new CompletionRegisteringAdvisor(() -> publicationRegistry, + EventListenerMethodMetadata.of(() -> environment)); } } diff --git a/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/support/CompletionRegisteringAdvisorUnitTests.java b/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/support/CompletionRegisteringAdvisorUnitTests.java index a8a0a12ef..136da788c 100644 --- a/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/support/CompletionRegisteringAdvisorUnitTests.java +++ b/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/support/CompletionRegisteringAdvisorUnitTests.java @@ -26,6 +26,9 @@ import org.springframework.aop.framework.Advised; import org.springframework.aop.framework.ProxyFactory; import org.springframework.context.event.EventListener; +import org.springframework.mock.env.MockEnvironment; +import org.springframework.modulith.events.ApplicationModuleListener; +import org.springframework.modulith.events.core.EventListenerMethodMetadata; import org.springframework.modulith.events.core.EventPublicationRegistry; import org.springframework.scheduling.annotation.Async; import org.springframework.transaction.event.TransactionPhase; @@ -38,8 +41,11 @@ */ class CompletionRegisteringAdvisorUnitTests { + static final String TRIGGER_ANNOTATION_PROPERTY = "spring.modulith.events.registry-trigger-annotation"; + EventPublicationRegistry registry = mock(EventPublicationRegistry.class); SomeEventListener bean = new SomeEventListener(); + MockEnvironment environment = new MockEnvironment(); @Test void triggersCompletionForAfterCommitEventListener() throws Exception { @@ -97,6 +103,27 @@ void exposesResultForCompletableFuture() throws Exception { assertThat(future.get()).isNotNull(); } + @Test // GH-1903 + void doesNotTriggerCompletionForListenerMissingTriggerAnnotation() { + + environment.setProperty(TRIGGER_ANNOTATION_PROPERTY, ApplicationModuleListener.class.getName()); + + createProxyFor(bean).onAfterCommit(new Object()); + + verify(registry, never()).markProcessing(any(), any()); + verify(registry, never()).markCompleted(any(), any()); + } + + @Test // GH-1903 + void triggersCompletionForListenerCarryingTriggerAnnotation() { + + environment.setProperty(TRIGGER_ANNOTATION_PROPERTY, ApplicationModuleListener.class.getName()); + + createProxyFor(bean).onModuleEvent(new Object()); + + verify(registry).markCompleted(any(), any()); + } + private void assertCompletion(BiConsumer consumer) { assertCompletion(consumer, true); } @@ -120,7 +147,8 @@ private void assertCompletion(BiConsumer consumer, bo private T createProxyFor(T bean) { ProxyFactory factory = new ProxyFactory(bean); - factory.addAdvisor(new CompletionRegisteringAdvisor(() -> registry)); + factory.addAdvisor(new CompletionRegisteringAdvisor(() -> registry, + EventListenerMethodMetadata.of(() -> environment))); return (T) factory.getProxy(); } @@ -132,6 +160,9 @@ void onAfterCommit(Object event) {} @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK) void onAfterRollback(Object object) {} + @ApplicationModuleListener + void onModuleEvent(Object event) {} + @EventListener void simpleEventListener(Object object) {} diff --git a/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/support/EventExternalizerModuleListenerUnitTests.java b/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/support/EventExternalizerModuleListenerUnitTests.java index 9922ef426..0f14ba7be 100644 --- a/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/support/EventExternalizerModuleListenerUnitTests.java +++ b/spring-modulith-events/spring-modulith-events-core/src/test/java/org/springframework/modulith/events/support/EventExternalizerModuleListenerUnitTests.java @@ -28,6 +28,7 @@ import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.modulith.events.Externalized; import org.springframework.modulith.events.RoutingTarget; @@ -114,6 +115,46 @@ public void testOnlyOneDownstreamExecutionAtATime() throws InterruptedException verify(mock, times(3)).externalize(any(Object.class), any(RoutingTarget.class)); } + @Test // GH-1896 + void evaluatesRoutingExpressionsAgainstOriginalEventNotMappedPayload() { + + var mock = mock(EventExternalizationTransport.class); + when(mock.externalize(any(), any())).thenReturn(CompletableFuture.completedFuture(null)); + + var configuration = externalizing() + .select(annotatedAsExternalized()) + .mapping(RoutedSample.class, __ -> new Mapped()) + .build(); + + var service = new EventExternalizerModuleListener(configuration, mock::externalize); + + service.externalize(new RoutedSample("42")).join(); + + var payload = ArgumentCaptor.forClass(Object.class); + var target = ArgumentCaptor.forClass(RoutingTarget.class); + + verify(mock).externalize(payload.capture(), target.capture()); + + assertThat(payload.getValue()).isInstanceOf(Mapped.class); + assertThat(target.getValue().getKey()).isEqualTo("42"); + } + @Externalized static class Sample {} + + @Externalized("target::#{getValue()}") + static class RoutedSample { + + private final String value; + + RoutedSample(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + } + + record Mapped() {} } diff --git a/spring-modulith-events/spring-modulith-events-jms/src/main/java/org/springframework/modulith/events/jms/JmsEventExternalizerConfiguration.java b/spring-modulith-events/spring-modulith-events-jms/src/main/java/org/springframework/modulith/events/jms/JmsEventExternalizerConfiguration.java index 27649f32c..2fea8760f 100644 --- a/spring-modulith-events/spring-modulith-events-jms/src/main/java/org/springframework/modulith/events/jms/JmsEventExternalizerConfiguration.java +++ b/spring-modulith-events/spring-modulith-events-jms/src/main/java/org/springframework/modulith/events/jms/JmsEventExternalizerConfiguration.java @@ -66,7 +66,7 @@ EventExternalizerModuleListener jmsEventExternalizer(EventExternalizationConfigu logger.debug("Registering domain event externalization to JMS…"); return new EventExternalizerModuleListener(configuration, - createJmsTransport(operations, serializer, factory)); + createJmsTransport(operations, serializer, factory), factory); } @AutoConfiguration diff --git a/spring-modulith-events/spring-modulith-events-kafka/src/main/java/org/springframework/modulith/events/kafka/KafkaEventExternalizerConfiguration.java b/spring-modulith-events/spring-modulith-events-kafka/src/main/java/org/springframework/modulith/events/kafka/KafkaEventExternalizerConfiguration.java index b4a4129c8..94c65c5c8 100644 --- a/spring-modulith-events/spring-modulith-events-kafka/src/main/java/org/springframework/modulith/events/kafka/KafkaEventExternalizerConfiguration.java +++ b/spring-modulith-events/spring-modulith-events-kafka/src/main/java/org/springframework/modulith/events/kafka/KafkaEventExternalizerConfiguration.java @@ -69,7 +69,7 @@ EventExternalizerModuleListener kafkaEventExternalizer(EventExternalizationConfi logger.debug("Registering domain event externalization to Kafka…"); return new EventExternalizerModuleListener(configuration, - createKafkaTransport(configuration, operations, factory)); + createKafkaTransport(configuration, operations, factory), factory); } @AutoConfiguration diff --git a/spring-modulith-events/spring-modulith-events-kafka/src/test/java/org/springframework/modulith/events/kafka/KafkaEventExternalizerConfigurationIntegrationTests.java b/spring-modulith-events/spring-modulith-events-kafka/src/test/java/org/springframework/modulith/events/kafka/KafkaEventExternalizerConfigurationIntegrationTests.java index f03d71921..f12972603 100644 --- a/spring-modulith-events/spring-modulith-events-kafka/src/test/java/org/springframework/modulith/events/kafka/KafkaEventExternalizerConfigurationIntegrationTests.java +++ b/spring-modulith-events/spring-modulith-events-kafka/src/test/java/org/springframework/modulith/events/kafka/KafkaEventExternalizerConfigurationIntegrationTests.java @@ -31,6 +31,7 @@ import org.springframework.boot.test.context.FilteredClassLoader; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.kafka.core.KafkaOperations; +import org.springframework.kafka.support.KafkaHeaders; import org.springframework.messaging.Message; import org.springframework.messaging.support.MessageBuilder; import org.springframework.modulith.events.EventExternalizationConfiguration; @@ -151,6 +152,41 @@ void publishesEventExternalizedAfterNamastackExternalization() { assertEventExternalizedPublished(OutboxHandler.class, (transport, event) -> transport.handle(event, null)); } + @Test // GH-1895 + void appliesMappingForNamastackExternalization() { + + var config = EventExternalizationConfiguration.defaults("org") + .mapping(Sample.class, __ -> new MappedSample()) + .build(); + + assertOutboxMessage(config, it -> { + assertThat(it.getPayload()).isInstanceOf(MappedSample.class); + }); + } + + @Test // GH-1896 + void evaluatesRoutingKeyAgainstOriginalEventWhenMapped() { + + var config = EventExternalizationConfiguration.defaults("org") + .mapping(RoutedSample.class, __ -> new MappedSample()) + .build(); + + basicSetup(config) + .run(ctxt -> { + + ctxt.getBean(EventExternalizerModuleListener.class).externalize(new RoutedSample("42")); + + var captor = ArgumentCaptor.forClass(Message.class); + verify(operations).send(captor.capture()); + + var message = captor.getValue(); + + assertThat(message.getPayload()).isInstanceOf(MappedSample.class); + assertThat(message.getHeaders().get(KafkaHeaders.KEY)).isEqualTo("42"); + assertThat(message.getHeaders().get(KafkaHeaders.TOPIC)).isEqualTo("orders"); + }); + } + private void assertMessage(EventExternalizationConfiguration configuration, Consumer> assertions) { basicSetup(configuration) @@ -165,6 +201,21 @@ private void assertMessage(EventExternalizationConfiguration configuration, Cons }); } + private void assertOutboxMessage(EventExternalizationConfiguration configuration, Consumer> assertions) { + + basicSetup(configuration) + .withPropertyValues(ExternalizationMode.PROPERTY + "=" + ExternalizationMode.OUTBOX) + .run(ctxt -> { + + ctxt.getBean(OutboxHandler.class).handle(new Sample(), null); + + var captor = ArgumentCaptor.forClass(Message.class); + verify(operations).send(captor.capture()); + + assertions.accept(captor.getValue()); + }); + } + private ApplicationContextRunner basicSetup() { return basicSetup(null); } @@ -208,4 +259,14 @@ private void assertEventExternalizedPublished(Class transportType, BiCons @Externalized record Sample() {} + + record MappedSample() {} + + @Externalized("orders::#{getValue()}") + record RoutedSample(String value) { + + public String getValue() { + return value; + } + } } diff --git a/spring-modulith-events/spring-modulith-events-messaging/src/main/java/org/springframework/modulith/events/messaging/SpringMessagingEventExternalizerConfiguration.java b/spring-modulith-events/spring-modulith-events-messaging/src/main/java/org/springframework/modulith/events/messaging/SpringMessagingEventExternalizerConfiguration.java index 1510fdc62..085240357 100644 --- a/spring-modulith-events/spring-modulith-events-messaging/src/main/java/org/springframework/modulith/events/messaging/SpringMessagingEventExternalizerConfiguration.java +++ b/spring-modulith-events/spring-modulith-events-messaging/src/main/java/org/springframework/modulith/events/messaging/SpringMessagingEventExternalizerConfiguration.java @@ -67,7 +67,8 @@ EventExternalizerModuleListener springMessagingEventExternalizer(EventExternaliz logger.debug("Registering domain event externalization for Spring Messaging…"); - return new EventExternalizerModuleListener(configuration, createMessagingTransport(configuration, factory)); + return new EventExternalizerModuleListener(configuration, createMessagingTransport(configuration, factory), + factory); } @AutoConfiguration diff --git a/spring-modulith-events/spring-modulith-events-messaging/src/test/java/org/springframework/modulith/events/messaging/SpringMessagingEventPublicationIntegrationTests.java b/spring-modulith-events/spring-modulith-events-messaging/src/test/java/org/springframework/modulith/events/messaging/SpringMessagingEventPublicationIntegrationTests.java index 258bf2ae9..60164b5c7 100644 --- a/spring-modulith-events/spring-modulith-events-messaging/src/test/java/org/springframework/modulith/events/messaging/SpringMessagingEventPublicationIntegrationTests.java +++ b/spring-modulith-events/spring-modulith-events-messaging/src/test/java/org/springframework/modulith/events/messaging/SpringMessagingEventPublicationIntegrationTests.java @@ -46,7 +46,8 @@ @SpringBootTest class SpringMessagingEventPublicationIntegrationTests { - private static final String TARGET = "target::#{someExpression}"; + private static final String TARGET = "target::#{getSomeExpression()}"; + private static final String RESOLVED_KEY = "resolved"; private static final String CHANNEL_NAME = "target"; private static final AtomicInteger COUNTER = new AtomicInteger(); @@ -69,7 +70,7 @@ IntegrationFlow inboundIntegrationFlow(@Qualifier(CHANNEL_NAME) MessageChannel i .handle((__, headers) -> { assertThat(headers.get(SpringMessagingEventExternalizerConfiguration.MODULITH_ROUTING_HEADER)) - .isEqualTo(TARGET); + .isEqualTo("target::" + RESOLVED_KEY); COUNTER.incrementAndGet(); return null; @@ -100,7 +101,12 @@ void publishesEventToSpringMessaging() throws Exception { } @Externalized(TARGET) - static class TestEvent {} + static class TestEvent { + + public String getSomeExpression() { + return RESOLVED_KEY; + } + } @RequiredArgsConstructor static class TestPublisher { diff --git a/spring-modulith-events/spring-modulith-events-namastack/src/main/java/org/springframework/modulith/events/namastack/NamastackOutboxEventRecorder.java b/spring-modulith-events/spring-modulith-events-namastack/src/main/java/org/springframework/modulith/events/namastack/NamastackOutboxEventRecorder.java index cbc288371..317ccddba 100644 --- a/spring-modulith-events/spring-modulith-events-namastack/src/main/java/org/springframework/modulith/events/namastack/NamastackOutboxEventRecorder.java +++ b/spring-modulith-events/spring-modulith-events-namastack/src/main/java/org/springframework/modulith/events/namastack/NamastackOutboxEventRecorder.java @@ -81,16 +81,15 @@ public void onApplicationEvent(PayloadApplicationEvent event) { } var target = configuration.determineTarget(payload); - var mapped = configuration.map(payload); var routing = BrokerRouting.of(target, context); - var key = routing.getKey(mapped); + var key = routing.getKey(payload); if (LOGGER.isDebugEnabled()) { LOGGER.debug("Scheduling event of type {} to outbox for target {}.", payload.getClass().getName(), target.getTarget()); } - scheduleToOutbox(mapped, key); + scheduleToOutbox(payload, key); } /** diff --git a/spring-modulith-events/spring-modulith-events-namastack/src/test/java/org/springframework/modulith/events/namastack/NamastackOutboxEventRecorderUnitTests.java b/spring-modulith-events/spring-modulith-events-namastack/src/test/java/org/springframework/modulith/events/namastack/NamastackOutboxEventRecorderUnitTests.java index 7323fd296..b9f2c0a5b 100644 --- a/spring-modulith-events/spring-modulith-events-namastack/src/test/java/org/springframework/modulith/events/namastack/NamastackOutboxEventRecorderUnitTests.java +++ b/spring-modulith-events/spring-modulith-events-namastack/src/test/java/org/springframework/modulith/events/namastack/NamastackOutboxEventRecorderUnitTests.java @@ -15,10 +15,13 @@ */ package org.springframework.modulith.events.namastack; +import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; import io.namastack.outbox.Outbox; +import java.util.concurrent.atomic.AtomicInteger; + import org.junit.jupiter.api.Test; import org.springframework.context.PayloadApplicationEvent; import org.springframework.expression.spel.support.StandardEvaluationContext; @@ -48,6 +51,30 @@ void evaluatesRoutingKeyExpressionAgainstEventPayload() { verify(outbox).schedule(payload, "value"); } + @Test // GH-1895 + void storesOriginalEventWithoutApplyingMapping() { + + var mappings = new AtomicInteger(); + var configuration = EventExternalizationConfiguration.externalizing() + .select(EventExternalizationConfiguration.annotatedAsExternalized()) + .mapping(SampleEvent.class, it -> { + mappings.incrementAndGet(); + return new MappedEvent(it.getValue()); + }) + .build(); + + var payload = new SampleEvent("value"); + var outbox = mock(Outbox.class); + var recorder = new NamastackOutboxEventRecorder(configuration, outbox, new StandardEvaluationContext()); + + recorder.onApplicationEvent(new PayloadApplicationEvent<>(this, payload)); + + verify(outbox).schedule(payload, "value"); + assertThat(mappings).hasValue(0); + } + + record MappedEvent(String value) {} + @Externalized("target::#{getValue()}") static class SampleEvent { diff --git a/spring-modulith-integration-test/src/test/java/com/acme/myproject/emptydiagram/package-info.java b/spring-modulith-integration-test/src/test/java/com/acme/myproject/emptydiagram/package-info.java new file mode 100644 index 000000000..d0e8ded23 --- /dev/null +++ b/spring-modulith-integration-test/src/test/java/com/acme/myproject/emptydiagram/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2020-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * An explicitly declared module without any Java types besides package metadata. + */ +@org.springframework.modulith.ApplicationModule +package com.acme.myproject.emptydiagram; diff --git a/spring-modulith-integration-test/src/test/java/org/springframework/modulith/docs/DocumenterUnitTests.java b/spring-modulith-integration-test/src/test/java/org/springframework/modulith/docs/DocumenterUnitTests.java index 6f3731cae..c8e058fd6 100644 --- a/spring-modulith-integration-test/src/test/java/org/springframework/modulith/docs/DocumenterUnitTests.java +++ b/spring-modulith-integration-test/src/test/java/org/springframework/modulith/docs/DocumenterUnitTests.java @@ -17,9 +17,13 @@ import static org.assertj.core.api.Assertions.*; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.List; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.springframework.modulith.core.ApplicationModules; import org.springframework.modulith.core.SpringBean; import org.springframework.modulith.docs.Documenter.CanvasOptions; @@ -67,6 +71,17 @@ void groupsSpringBeansByArchitecturallyEvidentType() { .containsOnly("SomeEventListener", "SomeTxEventListener"); } + @Test + void includesModulesWithoutRelationships(@TempDir Path outputFolder) throws IOException { + + var modules = ApplicationModules.of(Application.class); + var options = Documenter.Options.defaults().withOutputFolder(outputFolder.toString()); + + new Documenter(modules, options).writeModulesAsPlantUml(); + + assertThat(Files.readString(outputFolder.resolve("components.puml"))).contains("emptydiagram"); + } + @Test void playWithOutput() { diff --git a/src/docs/antora/modules/ROOT/pages/events.adoc b/src/docs/antora/modules/ROOT/pages/events.adoc index feba439cd..31486aa9b 100644 --- a/src/docs/antora/modules/ROOT/pages/events.adoc +++ b/src/docs/antora/modules/ROOT/pages/events.adoc @@ -206,7 +206,8 @@ If you want to customize this, check out the xref:appendix.adoc#configuration-pr .The transactional event listener arrangement before execution image::event-publication-registry-start.png[] -Each transactional event listener is wrapped into an aspect that marks that log entry as completed if the execution of the listener succeeds. +Each transactional event listener that triggers such a log entry is wrapped into an aspect that marks the entry as completed if the execution of the listener succeeds. +Listeners excluded via the trigger annotation described above are left untouched entirely. In case the listener fails, the log entry stays untouched so that retry mechanisms can be deployed depending on the application's needs. Automatic re-publication of the events can be enabled via the xref:appendix.adoc#configuration-properties[`spring.modulith.events.republish-outstanding-events-on-restart`] property.