This is an automated email from the ASF dual-hosted git repository. timothyjward pushed a commit to branch backport/records in repository https://gitbox.apache.org/repos/asf/aries-typedevent.git
commit f56ca7ccd24cf8760a1c64a18e6ec5fffa63bd84 Author: Tim Ward <[email protected]> AuthorDate: Wed Mar 6 15:58:40 2024 +0000 Backport support for Records as events from 1.1 dev branch This commit makes the Aries Typed Event bundle a multi-release bundle which works on Java 8, but adds support for Record types on Java 16 or later. Event Publishers and Event Handlers can use Record types as normal. The build now requires Java 17 (the LTS release) as a result of this change. Signed-off-by: Tim Ward <[email protected]> (cherry picked from commit 7b6220ac1c4b5d1736477789cdd259444896a39c) --- org.apache.aries.typedevent.bus/bnd.bnd | 1 + org.apache.aries.typedevent.bus/pom.xml | 27 ++ .../aries/typedevent/bus/impl/EventConverter.java | 10 +- .../aries/typedevent/bus/impl/RecordConverter.java | 36 +++ .../typedevent/bus/impl/TypedEventBusImpl.java | 2 +- .../aries/typedevent/bus/impl/RecordConverter.java | 102 ++++++++ .../typedevent/bus/osgi/RecordIntegrationTest.java | 272 +++++++++++++++++++++ pom.xml | 2 +- 8 files changed, 448 insertions(+), 4 deletions(-) diff --git a/org.apache.aries.typedevent.bus/bnd.bnd b/org.apache.aries.typedevent.bus/bnd.bnd new file mode 100644 index 0000000..6cd22a6 --- /dev/null +++ b/org.apache.aries.typedevent.bus/bnd.bnd @@ -0,0 +1 @@ +Multi-Release: true \ No newline at end of file diff --git a/org.apache.aries.typedevent.bus/pom.xml b/org.apache.aries.typedevent.bus/pom.xml index 0bba981..8a62413 100644 --- a/org.apache.aries.typedevent.bus/pom.xml +++ b/org.apache.aries.typedevent.bus/pom.xml @@ -109,6 +109,33 @@ <groupId>biz.aQute.bnd</groupId> <artifactId>bnd-run-maven-plugin</artifactId> </plugin> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-compiler-plugin</artifactId> + <executions> + <execution> + <id>java16-compile</id> + <goals> + <goal>compile</goal> + </goals> + <configuration> + <compileSourceRoots>${project.basedir}/src/main/java16</compileSourceRoots> + <source>16</source> + <target>16</target> + <release>16</release> + <multiReleaseOutput>true</multiReleaseOutput> + </configuration> + </execution> + <execution> + <id>default-testCompile</id> + <configuration> + <source>16</source> + <target>16</target> + <release>16</release> + </configuration> + </execution> + </executions> + </plugin> </plugins> </build> </project> \ No newline at end of file diff --git a/org.apache.aries.typedevent.bus/src/main/java/org/apache/aries/typedevent/bus/impl/EventConverter.java b/org.apache.aries.typedevent.bus/src/main/java/org/apache/aries/typedevent/bus/impl/EventConverter.java index d2f8851..3fc5588 100644 --- a/org.apache.aries.typedevent.bus/src/main/java/org/apache/aries/typedevent/bus/impl/EventConverter.java +++ b/org.apache.aries.typedevent.bus/src/main/java/org/apache/aries/typedevent/bus/impl/EventConverter.java @@ -66,7 +66,7 @@ public class EventConverter { }; private static final TypeReference<Set<Object>> SET_OF_OBJECTS = new TypeReference<Set<Object>>() { }; - private static final TypeReference<Map<String, Object>> MAP_WITH_STRING_KEYS = new TypeReference<Map<String, Object>>() { + static final TypeReference<Map<String, Object>> MAP_WITH_STRING_KEYS = new TypeReference<Map<String, Object>>() { }; private static final TypeReference<Map<Object, Object>> MAP_OF_OBJECT_TO_OBJECT = new TypeReference<Map<Object, Object>>() { }; @@ -105,10 +105,16 @@ public class EventConverter { specialClasses.add(ZonedDateTime.class); specialClasses.add(UUID.class); - eventConverter = Converters.standardConverter().newConverterBuilder().rule(EventConverter::convert) + eventConverter = Converters.standardConverter().newConverterBuilder() + .rule(EventConverter::convertRecord) + .rule(EventConverter::convert) .errorHandler(EventConverter::attemptRecovery).build(); } + static Object convertRecord(Object o, Type target) { + return RecordConverter.convert(eventConverter, o, target); + } + /** * Conversion for nested Map values * @param o - the value to convert diff --git a/org.apache.aries.typedevent.bus/src/main/java/org/apache/aries/typedevent/bus/impl/RecordConverter.java b/org.apache.aries.typedevent.bus/src/main/java/org/apache/aries/typedevent/bus/impl/RecordConverter.java new file mode 100644 index 0000000..ef0a516 --- /dev/null +++ b/org.apache.aries.typedevent.bus/src/main/java/org/apache/aries/typedevent/bus/impl/RecordConverter.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 + * + * http://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.apache.aries.typedevent.bus.impl; + +import java.lang.reflect.Type; + +import org.osgi.util.converter.Converter; +import org.osgi.util.converter.ConverterFunction; + +/** + * This class is responsible for converting Record events to and from their + * "flattened" representations. As Java 8 doesn't support Records this is + * not handled + */ +public class RecordConverter { + + static Object convert(Converter converter, Object o, Type target) { + return ConverterFunction.CANNOT_HANDLE; + } + +} \ No newline at end of file diff --git a/org.apache.aries.typedevent.bus/src/main/java/org/apache/aries/typedevent/bus/impl/TypedEventBusImpl.java b/org.apache.aries.typedevent.bus/src/main/java/org/apache/aries/typedevent/bus/impl/TypedEventBusImpl.java index 7398224..85ea79c 100644 --- a/org.apache.aries.typedevent.bus/src/main/java/org/apache/aries/typedevent/bus/impl/TypedEventBusImpl.java +++ b/org.apache.aries.typedevent.bus/src/main/java/org/apache/aries/typedevent/bus/impl/TypedEventBusImpl.java @@ -178,7 +178,7 @@ public class TypedEventBusImpl implements TypedEventBus, AriesTypedEvents { } } else { Class<?> toCheck = handler.getClass(); - outer: while(genType == null) { + outer: while(genType == null && toCheck != null) { genType = findDirectlyImplemented(toCheck); if(genType != null) { diff --git a/org.apache.aries.typedevent.bus/src/main/java16/org/apache/aries/typedevent/bus/impl/RecordConverter.java b/org.apache.aries.typedevent.bus/src/main/java16/org/apache/aries/typedevent/bus/impl/RecordConverter.java new file mode 100644 index 0000000..1e32c3c --- /dev/null +++ b/org.apache.aries.typedevent.bus/src/main/java16/org/apache/aries/typedevent/bus/impl/RecordConverter.java @@ -0,0 +1,102 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 + * + * http://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.apache.aries.typedevent.bus.impl; + +import static java.util.stream.Collectors.toMap; + +import java.lang.reflect.RecordComponent; +import java.lang.reflect.Type; +import java.util.Arrays; +import java.util.Map; + +import org.osgi.util.converter.ConversionException; +import org.osgi.util.converter.Converter; +import org.osgi.util.converter.ConverterFunction; + +/** + * This class is responsible for converting Record events to and from their + * "flattened" representations. This version runs on Java 17 + */ +public class RecordConverter { + + static Object convert(Converter converter, Object o, Type target) { + + if (Record.class.isInstance(o)) { + RecordComponent[] sourceComponents = o.getClass().getRecordComponents(); + + if(target instanceof Class<?> clz && Record.class.isAssignableFrom(clz)) { + RecordComponent[] targetComponents = clz.getRecordComponents(); + Object[] args = new Object[targetComponents.length]; + Class<?>[] argTypes = new Class<?>[targetComponents.length]; + for(int i = 0; i < targetComponents.length; i++) { + RecordComponent targetComponent = targetComponents[i]; + String name = targetComponent.getName(); + Object arg = null; + for(int j = 0; j < sourceComponents.length; j++) { + if(sourceComponents[j].getName().equals(name)) { + Object sourceArg = getComponentValue(sourceComponents[j], o); + Type targetArgType = targetComponent.getGenericType(); + arg = converter.convert(sourceArg).to(targetArgType); + break; + } + } + args[i] = arg; + argTypes[i] = targetComponent.getType(); + } + return createRecord(clz, args, argTypes); + } else { + Map<String, Object> converted = Arrays.stream(sourceComponents) + .collect(toMap(RecordComponent::getName, rc -> getComponentValue(rc, o))); + + return converter.convert(converted).to(target); + } + } else if(target instanceof Class<?> clz && Record.class.isAssignableFrom(clz)) { + Map<String, Object> intermediate = converter.convert(o).to(EventConverter.MAP_WITH_STRING_KEYS); + RecordComponent[] targetComponents = clz.getRecordComponents(); + Object[] args = new Object[targetComponents.length]; + Class<?>[] argTypes = new Class<?>[targetComponents.length]; + for(int i = 0; i < targetComponents.length; i++) { + RecordComponent targetComponent = targetComponents[i]; + Object sourceArg = intermediate.get(targetComponent.getName()); + Type targetArgType = targetComponent.getGenericType(); + args[i] = converter.convert(sourceArg).to(targetArgType); + argTypes[i] = targetComponent.getType(); + } + return createRecord(clz, args, argTypes); + } + + return ConverterFunction.CANNOT_HANDLE; + + } + + private static Object createRecord(Class<?> clz, Object[] args, Class<?>[] argTypes) { + try { + return clz.getDeclaredConstructor(argTypes).newInstance(args); + } catch (Exception e) { + throw new ConversionException("Unable to instantiate record component " + clz.getName(), e); + } + } + + private static Object getComponentValue(RecordComponent rc, Object o) { + try { + return rc.getAccessor().invoke(o); + } catch (Exception e) { + throw new ConversionException("Unable to process record component " + rc.getName() + " from type " + rc.getDeclaringRecord().getName(), e); + } + } +} \ No newline at end of file diff --git a/org.apache.aries.typedevent.bus/src/test/java/org/apache/aries/typedevent/bus/osgi/RecordIntegrationTest.java b/org.apache.aries.typedevent.bus/src/test/java/org/apache/aries/typedevent/bus/osgi/RecordIntegrationTest.java new file mode 100644 index 0000000..8b5d69d --- /dev/null +++ b/org.apache.aries.typedevent.bus/src/test/java/org/apache/aries/typedevent/bus/osgi/RecordIntegrationTest.java @@ -0,0 +1,272 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 + * + * http://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 eventBusied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.aries.typedevent.bus.osgi; + +import static org.osgi.service.typedevent.TypedEventConstants.TYPED_EVENT_FILTER; +import static org.osgi.service.typedevent.TypedEventConstants.TYPED_EVENT_TOPICS; + +import java.util.Dictionary; +import java.util.Hashtable; + +import org.apache.aries.typedevent.bus.common.TestEvent; +import org.apache.aries.typedevent.bus.common.TestEventConsumer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentMatcher; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.osgi.framework.BundleContext; +import org.osgi.service.typedevent.TypedEventBus; +import org.osgi.service.typedevent.TypedEventHandler; +import org.osgi.test.common.annotation.InjectBundleContext; +import org.osgi.test.common.annotation.InjectService; +import org.osgi.test.junit5.context.BundleContextExtension; +import org.osgi.test.junit5.service.ServiceExtension; + +/** + * This is a JUnit test that will be run inside an OSGi framework. + * + * It can interact with the framework by starting or stopping bundles, + * getting or registering services, or in other ways, and then observing + * the result on the bundle(s) being tested. + */ +@ExtendWith(BundleContextExtension.class) +@ExtendWith(ServiceExtension.class) +@ExtendWith(MockitoExtension.class) +public class RecordIntegrationTest extends AbstractIntegrationTest { + + private static final String TOPIC = "org/apache/aries/test/record"; + + @InjectBundleContext + BundleContext context; + + @InjectService + TypedEventBus eventBus; + + @Mock + TestEventConsumer typedEventHandler; + + @Mock + TestRecordListener recordEventHandler; + + @Test + public void testUnFilteredListenerEventToRecord() throws Exception { + Dictionary<String, Object> props = new Hashtable<>(); + props.put(TYPED_EVENT_TOPICS, TOPIC); + + regs.add(context.registerService(TypedEventHandler.class, typedEventHandler, props)); + + props = new Hashtable<>(); + props.put(TYPED_EVENT_TOPICS, TOPIC); + + regs.add(context.registerService(TypedEventHandler.class, recordEventHandler, props)); + + // Event to record + + TestEvent event = new TestEvent(); + event.message = "foo"; + + eventBus.deliver(TOPIC, event); + + Mockito.verify(typedEventHandler, Mockito.timeout(1000)) + .notify(Mockito.eq(TOPIC), Mockito.argThat(isTestEventWithMessage("foo"))); + + Mockito.verify(recordEventHandler, Mockito.timeout(1000)) + .notify(Mockito.eq(TOPIC), Mockito.argThat(isTestRecordWithMessage("foo"))); + } + + @Test + public void testUnFilteredListenerRecordToEvent() throws Exception { + Dictionary<String, Object> props = new Hashtable<>(); + props.put(TYPED_EVENT_TOPICS, TOPIC); + + regs.add(context.registerService(TypedEventHandler.class, typedEventHandler, props)); + + props = new Hashtable<>(); + props.put(TYPED_EVENT_TOPICS, TOPIC); + + regs.add(context.registerService(TypedEventHandler.class, recordEventHandler, props)); + + // Record to Event + TestRecord testRecord = new TestRecord("bar"); + + eventBus.deliver(TOPIC, testRecord); + + Mockito.verify(typedEventHandler, Mockito.timeout(1000)) + .notify(Mockito.eq(TOPIC), Mockito.argThat(isTestEventWithMessage("bar"))); + + Mockito.verify(recordEventHandler, Mockito.timeout(1000)) + .notify(Mockito.eq(TOPIC), Mockito.argThat(isTestRecordWithMessage("bar"))); + + } + + @Test + public void testUnFilteredListenerRecordToRecord() throws Exception { + Dictionary<String, Object> props = new Hashtable<>(); + props.put(TYPED_EVENT_TOPICS, TOPIC); + + regs.add(context.registerService(TypedEventHandler.class, typedEventHandler, props)); + + props = new Hashtable<>(); + props.put(TYPED_EVENT_TOPICS, TOPIC); + + regs.add(context.registerService(TypedEventHandler.class, recordEventHandler, props)); + + // Record to Record + TestRecord2 testRecord2 = new TestRecord2("foobar", 5); + + eventBus.deliver(TOPIC, testRecord2); + + Mockito.verify(typedEventHandler, Mockito.timeout(1000)) + .notify(Mockito.eq(TOPIC), Mockito.argThat(isTestEventWithMessage("foobar"))); + + Mockito.verify(recordEventHandler, Mockito.timeout(1000)) + .notify(Mockito.eq(TOPIC), Mockito.argThat(isTestRecordWithMessage("foobar"))); + + } + + @Test + public void testFilteredListenerEventToRecord() throws Exception { + Dictionary<String, Object> props = new Hashtable<>(); + props.put(TYPED_EVENT_FILTER, "(message=foo)"); + props.put(TYPED_EVENT_TOPICS, TOPIC); + + regs.add(context.registerService(TypedEventHandler.class, typedEventHandler, props)); + + props = new Hashtable<>(); + props.put(TYPED_EVENT_FILTER, "(message=bar)"); + props.put(TYPED_EVENT_TOPICS, TOPIC); + + regs.add(context.registerService(TypedEventHandler.class, recordEventHandler, props)); + + // Event to record + + TestEvent event = new TestEvent(); + event.message = "foo"; + + eventBus.deliver(TOPIC, event); + + Mockito.verify(typedEventHandler, Mockito.timeout(1000)) + .notify(Mockito.eq(TOPIC), Mockito.argThat(isTestEventWithMessage("foo"))); + + Mockito.verify(recordEventHandler, Mockito.after(1000).never()) + .notify(Mockito.eq(TOPIC), Mockito.argThat(isTestRecordWithMessage("foo"))); + + + event = new TestEvent(); + event.message = "bar"; + + eventBus.deliver(TOPIC, event); + + Mockito.verify(recordEventHandler, Mockito.timeout(1000)) + .notify(Mockito.eq(TOPIC), Mockito.argThat(isTestRecordWithMessage("bar"))); + + Mockito.verify(typedEventHandler, Mockito.after(1000).never()) + .notify(Mockito.eq(TOPIC), Mockito.argThat(isTestEventWithMessage("bar"))); + } + + @Test + public void testFilteredListenerRecordToEvent() throws Exception { + Dictionary<String, Object> props = new Hashtable<>(); + props.put(TYPED_EVENT_FILTER, "(message=foo)"); + props.put(TYPED_EVENT_TOPICS, TOPIC); + + regs.add(context.registerService(TypedEventHandler.class, typedEventHandler, props)); + + props = new Hashtable<>(); + props.put(TYPED_EVENT_FILTER, "(message=bar)"); + props.put(TYPED_EVENT_TOPICS, TOPIC); + + regs.add(context.registerService(TypedEventHandler.class, recordEventHandler, props)); + + // Record to Event + TestRecord testRecord = new TestRecord("foo"); + + eventBus.deliver(TOPIC, testRecord); + + Mockito.verify(typedEventHandler, Mockito.timeout(1000)) + .notify(Mockito.eq(TOPIC), Mockito.argThat(isTestEventWithMessage("foo"))); + + Mockito.verify(recordEventHandler, Mockito.after(1000).never()) + .notify(Mockito.eq(TOPIC), Mockito.argThat(isTestRecordWithMessage("foo"))); + + + testRecord = new TestRecord("bar"); + + eventBus.deliver(TOPIC, testRecord); + + Mockito.verify(recordEventHandler, Mockito.timeout(1000)) + .notify(Mockito.eq(TOPIC), Mockito.argThat(isTestRecordWithMessage("bar"))); + + Mockito.verify(typedEventHandler, Mockito.after(1000).never()) + .notify(Mockito.eq(TOPIC), Mockito.argThat(isTestEventWithMessage("bar"))); + } + + @Test + public void testFilteredListenerRecordToRecord() throws Exception { + Dictionary<String, Object> props = new Hashtable<>(); + props.put(TYPED_EVENT_FILTER, "(message=foo)"); + props.put(TYPED_EVENT_TOPICS, TOPIC); + + regs.add(context.registerService(TypedEventHandler.class, typedEventHandler, props)); + + props = new Hashtable<>(); + props.put(TYPED_EVENT_FILTER, "(message=bar)"); + props.put(TYPED_EVENT_TOPICS, TOPIC); + + regs.add(context.registerService(TypedEventHandler.class, recordEventHandler, props)); + + // Record to Record + TestRecord2 testRecord2 = new TestRecord2("foo", 5); + + eventBus.deliver(TOPIC, testRecord2); + + Mockito.verify(typedEventHandler, Mockito.timeout(1000)) + .notify(Mockito.eq(TOPIC), Mockito.argThat(isTestEventWithMessage("foo"))); + + Mockito.verify(recordEventHandler, Mockito.after(1000).never()) + .notify(Mockito.eq(TOPIC), Mockito.argThat(isTestRecordWithMessage("foo"))); + + + testRecord2 = new TestRecord2("bar", 5); + + eventBus.deliver(TOPIC, testRecord2); + + Mockito.verify(recordEventHandler, Mockito.timeout(1000)) + .notify(Mockito.eq(TOPIC), Mockito.argThat(isTestRecordWithMessage("bar"))); + + Mockito.verify(typedEventHandler, Mockito.after(1000).never()) + .notify(Mockito.eq(TOPIC), Mockito.argThat(isTestEventWithMessage("bar"))); + } + + public interface TestRecordListener extends TypedEventHandler<TestRecord> {} + + public record TestRecord(String message) {} + + public record TestRecord2(String message, int count) {} + + protected ArgumentMatcher<TestRecord> isTestRecordWithMessage(String message) { + return new ArgumentMatcher<TestRecord>() { + + @Override + public boolean matches(TestRecord argument) { + return message.equals(argument.message); + } + }; + } +} \ No newline at end of file diff --git a/pom.xml b/pom.xml index 52d4c2c..f85a904 100644 --- a/pom.xml +++ b/pom.xml @@ -58,7 +58,7 @@ </repositories> <properties> - <bnd.version>6.4.0</bnd.version> + <bnd.version>7.0.0</bnd.version> <dsl.version>1.2.2</dsl.version> <junit.version>5.10.0</junit.version> <mockito.version>5.5.0</mockito.version>
