diff --git a/CHANGELOG.md b/CHANGELOG.md index e87af0ad66..421fe3305f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- [Java] Support Provider instances with Pico Container ([#2879](https://github.com/cucumber/cucumber-jvm/issues/2879), [#3128](https://github.com/cucumber/cucumber-jvm/pull/3128) Stefan Gasterstädt) + ## [7.33.0] - 2025-12-09 ### Added - [Java] Add `Scenario.getLanguage()` to return the current language ([#3124](https://github.com/cucumber/cucumber-jvm/pull/3124) Stefan Gasterstädt) diff --git a/cucumber-picocontainer/README.md b/cucumber-picocontainer/README.md index 430f88ac72..881e7064b1 100644 --- a/cucumber-picocontainer/README.md +++ b/cucumber-picocontainer/README.md @@ -123,3 +123,27 @@ customization. If you want to customize your dependency injection context, it is recommended to provide your own implementation of `io.cucumber.core.backend.ObjectFactory` and make it available through SPI. + +However it is possible to configure additional PicoContainer `Provider`s. For +example, some step definition classes might require a database connection as a +constructor argument. + +```java +package com.example.app; + +import java.sql.*; +import io.cucumber.picocontainer.CucumberPicoProvider; +import org.picocontainer.injectors.Provider; + +@CucumberPicoProvider +public class DatabaseConnectionProvider implements Provider { + + public Connection provide() throws ClassNotFoundException, ReflectiveOperationException, SQLException { + // Connecting to MySQL Using the JDBC DriverManager Interface + // https://dev.mysql.com/doc/connector-j/en/connector-j-usagenotes-connect-drivermanager.html + Class.forName("com.mysql.cj.jdbc.Driver").getDeclaredConstructor().newInstance(); + return DriverManager.getConnection("jdbc:mysql://localhost:3306/mydb", "mydbuser", "mydbpassword"); + } + +} +``` diff --git a/cucumber-picocontainer/pom.xml b/cucumber-picocontainer/pom.xml index c29f43932f..5b90c60a5b 100644 --- a/cucumber-picocontainer/pom.xml +++ b/cucumber-picocontainer/pom.xml @@ -16,6 +16,7 @@ 2.15.2 1.1.2 5.14.1 + 5.20.0 @@ -72,6 +73,12 @@ junit-vintage-engine test + + org.mockito + mockito-junit-jupiter + ${mockito.version} + test + diff --git a/cucumber-picocontainer/src/main/java/io/cucumber/picocontainer/CucumberPicoProvider.java b/cucumber-picocontainer/src/main/java/io/cucumber/picocontainer/CucumberPicoProvider.java new file mode 100644 index 0000000000..e9ac842e4e --- /dev/null +++ b/cucumber-picocontainer/src/main/java/io/cucumber/picocontainer/CucumberPicoProvider.java @@ -0,0 +1,78 @@ +package io.cucumber.picocontainer; + +import org.apiguardian.api.API; +import org.picocontainer.MutablePicoContainer; +import org.picocontainer.injectors.Provider; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * This annotation is used to provide some additional PicoContainer + * {@link Provider} classes. + *

+ * An example is: + * + *

+ * package some.example;
+ *
+ * import java.sql.*;
+ * import io.cucumber.picocontainer.CucumberPicoProvider;
+ * import org.picocontainer.injectors.Provider;
+ *
+ * @CucumberPicoProvider
+ * public class DatabaseConnectionProvider implements Provider {
+ *     public Connection provide() throws ClassNotFoundException, ReflectiveOperationException, SQLException {
+ *         // Connecting to MySQL Using the JDBC DriverManager Interface
+ *         // https://dev.mysql.com/doc/connector-j/en/connector-j-usagenotes-connect-drivermanager.html
+ *         Class.forName("com.mysql.cj.jdbc.Driver").getDeclaredConstructor().newInstance();
+ *         return DriverManager.getConnection("jdbc:mysql://localhost:3306/mydb", "mydbuser", "mydbpassword");
+ *     }
+ * }
+ * 
+ *

+ * In order to re-use existing {@link Provider}s, you can refer to those like + * this: + * + *

+ * package some.example;
+ *
+ * import io.cucumber.picocontainer.CucumberPicoProvider;
+ * import some.other.namespace.SomeExistingProvider.class;
+ *
+ * @CucumberPicoProvider(providers = { SomeExistingProvider.class })
+ * public class MyCucumberPicoProviders {
+ * }
+ * 
+ *

+ * Notes: + *

+ */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@API(status = API.Status.EXPERIMENTAL) +public @interface CucumberPicoProvider { + + Class[] providers() default {}; + +} diff --git a/cucumber-picocontainer/src/main/java/io/cucumber/picocontainer/PicoBackend.java b/cucumber-picocontainer/src/main/java/io/cucumber/picocontainer/PicoBackend.java new file mode 100644 index 0000000000..7990f2c4b7 --- /dev/null +++ b/cucumber-picocontainer/src/main/java/io/cucumber/picocontainer/PicoBackend.java @@ -0,0 +1,65 @@ +package io.cucumber.picocontainer; + +import io.cucumber.core.backend.Backend; +import io.cucumber.core.backend.Container; +import io.cucumber.core.backend.Glue; +import io.cucumber.core.backend.Snippet; +import io.cucumber.core.resource.ClasspathScanner; +import io.cucumber.core.resource.ClasspathSupport; + +import java.net.URI; +import java.util.Collection; +import java.util.List; +import java.util.function.Supplier; +import java.util.stream.Stream; + +import static io.cucumber.core.resource.ClasspathSupport.CLASSPATH_SCHEME; +import static io.cucumber.picocontainer.PicoFactory.isProvider; +import static java.util.Arrays.stream; +import static java.util.stream.Stream.concat; + +final class PicoBackend implements Backend { + + private final Container container; + private final ClasspathScanner classFinder; + + PicoBackend(Container container, Supplier classLoaderSupplier) { + this.container = container; + this.classFinder = new ClasspathScanner(classLoaderSupplier); + } + + @Override + public void loadGlue(Glue glue, List gluePaths) { + gluePaths.stream() + .filter(gluePath -> CLASSPATH_SCHEME.equals(gluePath.getScheme())) + .map(ClasspathSupport::packageName) + .map(classFinder::scanForClassesInPackage) + .flatMap(Collection::stream) + .filter(clazz -> clazz.isAnnotationPresent(CucumberPicoProvider.class)) + .flatMap(clazz -> { + CucumberPicoProvider annotation = clazz.getAnnotation(CucumberPicoProvider.class); + if (isProvider(clazz)) { + return concat(Stream.of(clazz), stream(annotation.providers())); + } else { + return stream(annotation.providers()); + } + + }) + .distinct() + .forEach(container::addClass); + } + + @Override + public void buildWorld() { + } + + @Override + public void disposeWorld() { + } + + @Override + public Snippet getSnippet() { + return null; + } + +} diff --git a/cucumber-picocontainer/src/main/java/io/cucumber/picocontainer/PicoBackendProviderService.java b/cucumber-picocontainer/src/main/java/io/cucumber/picocontainer/PicoBackendProviderService.java new file mode 100644 index 0000000000..93da27a830 --- /dev/null +++ b/cucumber-picocontainer/src/main/java/io/cucumber/picocontainer/PicoBackendProviderService.java @@ -0,0 +1,17 @@ +package io.cucumber.picocontainer; + +import io.cucumber.core.backend.Backend; +import io.cucumber.core.backend.BackendProviderService; +import io.cucumber.core.backend.Container; +import io.cucumber.core.backend.Lookup; + +import java.util.function.Supplier; + +public final class PicoBackendProviderService implements BackendProviderService { + + @Override + public Backend create(Lookup lookup, Container container, Supplier classLoader) { + return new PicoBackend(container, classLoader); + } + +} diff --git a/cucumber-picocontainer/src/main/java/io/cucumber/picocontainer/PicoFactory.java b/cucumber-picocontainer/src/main/java/io/cucumber/picocontainer/PicoFactory.java index 67213438c4..b5dec00429 100644 --- a/cucumber-picocontainer/src/main/java/io/cucumber/picocontainer/PicoFactory.java +++ b/cucumber-picocontainer/src/main/java/io/cucumber/picocontainer/PicoFactory.java @@ -1,10 +1,14 @@ package io.cucumber.picocontainer; +import io.cucumber.core.backend.CucumberBackendException; import io.cucumber.core.backend.ObjectFactory; import org.apiguardian.api.API; import org.picocontainer.MutablePicoContainer; import org.picocontainer.PicoBuilder; +import org.picocontainer.PicoException; import org.picocontainer.behaviors.Cached; +import org.picocontainer.injectors.Provider; +import org.picocontainer.injectors.ProviderAdapter; import org.picocontainer.lifecycle.DefaultLifecycleState; import java.lang.reflect.Constructor; @@ -31,34 +35,90 @@ public void start() { .withCaching() .withLifecycle() .build(); + Set> providers = new HashSet<>(); + Set> providedClasses = new HashSet<>(); for (Class clazz : classes) { - pico.addComponent(clazz); + if (isProvider(clazz)) { + providers.add(clazz); + ProviderAdapter adapter = adapterForProviderClass(clazz); + pico.addAdapter(adapter); + providedClasses.add(adapter.getComponentImplementation()); + } + } + for (Class clazz : classes) { + // do not add the classes that represent a picocontainer + // Provider, and also do not add those raw classes that are + // already provided (otherwise this causes exceptional + // situations, e.g. PicoCompositionException with message + // "Duplicate Keys not allowed. Duplicate for 'class XXX'") + if (!providers.contains(clazz) && !providedClasses.contains(clazz)) { + pico.addComponent(clazz); + } } } else { // we already get a pico container which is in "disposed" lifecycle, // so recycle it by defining a new lifecycle and removing all // instances pico.setLifecycleState(new DefaultLifecycleState()); - pico.getComponentAdapters() - .forEach(cached -> ((Cached) cached).flush()); + pico.getComponentAdapters().forEach(adapters -> { + if (adapters instanceof Cached) { + ((Cached) adapters).flush(); + } + }); } pico.start(); } + static boolean isProvider(Class clazz) { + return Provider.class.isAssignableFrom(clazz); + } + + static boolean isProviderAdapter(Class clazz) { + return ProviderAdapter.class.isAssignableFrom(clazz); + } + + private static ProviderAdapter adapterForProviderClass(Class clazz) { + try { + Provider provider = (Provider) clazz.getDeclaredConstructor().newInstance(); + return isProviderAdapter(clazz) ? (ProviderAdapter) provider : new ProviderAdapter(provider); + } catch (ReflectiveOperationException | IllegalArgumentException | SecurityException | PicoException e) { + throw new CucumberBackendException(e.getMessage(), e); + } + } + @Override public void stop() { - pico.stop(); + if (pico.getLifecycleState().isStarted()) { + pico.stop(); + } pico.dispose(); } @Override public boolean addClass(Class clazz) { + checkMeaningfulPicoAnnotation(clazz); if (isInstantiable(clazz) && classes.add(clazz)) { addConstructorDependencies(clazz); } return true; } + private static void checkMeaningfulPicoAnnotation(Class clazz) { + if (clazz.isAnnotationPresent(CucumberPicoProvider.class)) { + CucumberPicoProvider annotation = clazz.getAnnotation(CucumberPicoProvider.class); + if (!isProvider(clazz) && (annotation.providers().length == 0)) { + throw new CucumberBackendException(String.format("" + + "Glue class %1$s was annotated with @CucumberPicoProvider; marking it as a candidate for declaring " + + + "PicoContainer Provider classes. Please ensure that at least one the following requirements is satisfied:\n" + + + "1) the class implements org.picocontainer.injectors.Provider\n" + + "2) the annotation #providers() refers to at least one class implementing org.picocontainer.injectors.Provider", + clazz.getName())); + } + } + } + @Override public T getInstance(Class type) { return pico.getComponent(type); diff --git a/cucumber-picocontainer/src/main/resources/META-INF/services/io.cucumber.core.backend.BackendProviderService b/cucumber-picocontainer/src/main/resources/META-INF/services/io.cucumber.core.backend.BackendProviderService new file mode 100644 index 0000000000..682c8c5dcf --- /dev/null +++ b/cucumber-picocontainer/src/main/resources/META-INF/services/io.cucumber.core.backend.BackendProviderService @@ -0,0 +1 @@ +io.cucumber.picocontainer.PicoBackendProviderService diff --git a/cucumber-picocontainer/src/test/java/io/cucumber/picocontainer/PicoBackendTest.java b/cucumber-picocontainer/src/test/java/io/cucumber/picocontainer/PicoBackendTest.java new file mode 100644 index 0000000000..939209a876 --- /dev/null +++ b/cucumber-picocontainer/src/test/java/io/cucumber/picocontainer/PicoBackendTest.java @@ -0,0 +1,86 @@ +package io.cucumber.picocontainer; + +import io.cucumber.core.backend.Glue; +import io.cucumber.core.backend.ObjectFactory; +import io.cucumber.picocontainer.annotationconfig.DatabaseConnectionProvider; +import io.cucumber.picocontainer.annotationconfig.ExamplePicoConfiguration; +import io.cucumber.picocontainer.annotationconfig.URLConnectionProvider; +import io.cucumber.picocontainer.annotationconfig.UrlToUriProvider; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.net.URI; + +import static java.lang.Thread.currentThread; +import static java.util.Arrays.asList; +import static java.util.Collections.singletonList; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class PicoBackendTest { + + @Mock + private Glue glue; + + @Mock + private ObjectFactory factory; + + private PicoBackend backend; + + @BeforeEach + void createBackend() { + this.backend = new PicoBackend(this.factory, currentThread()::getContextClassLoader); + } + + @Test + void considers_but_does_not_add_annotated_configuration() { + backend.loadGlue(glue, + singletonList(URI.create("classpath:io/cucumber/picocontainer/annotationconfig"))); + backend.buildWorld(); + verify(factory, never()).addClass(ExamplePicoConfiguration.class); + } + + @Test + void adds_referenced_provider_classes() { + backend.loadGlue(glue, + singletonList(URI.create("classpath:io/cucumber/picocontainer/annotationconfig"))); + backend.buildWorld(); + verify(factory).addClass(URLConnectionProvider.class); + verify(factory).addClass(DatabaseConnectionProvider.class); + } + + @Test + void adds_selfsufficient_provider_classes() { + backend.loadGlue(glue, + singletonList(URI.create("classpath:io/cucumber/picocontainer/annotationconfig"))); + backend.buildWorld(); + verify(factory).addClass(ExamplePicoConfiguration.NestedUrlProvider.class); + } + + @Test + void adds_nested_provider_classes() { + backend.loadGlue(glue, + singletonList(URI.create("classpath:io/cucumber/picocontainer/annotationconfig"))); + backend.buildWorld(); + verify(factory).addClass(UrlToUriProvider.class); + } + + @Test + void finds_configured_classes_only_once_when_scanning_twice() { + backend.loadGlue(glue, asList( + URI.create("classpath:io/cucumber/picocontainer/annotationconfig"), + URI.create("classpath:io/cucumber/picocontainer/annotationconfig"))); + backend.buildWorld(); + verify(factory, never()).addClass(ExamplePicoConfiguration.class); + verify(factory, times(1)).addClass(URLConnectionProvider.class); + verify(factory, times(1)).addClass(DatabaseConnectionProvider.class); + verify(factory, times(1)).addClass(ExamplePicoConfiguration.NestedUrlProvider.class); + verify(factory, times(1)).addClass(UrlToUriProvider.class); + } + +} diff --git a/cucumber-picocontainer/src/test/java/io/cucumber/picocontainer/annotationconfig/DatabaseConnectionProvider.java b/cucumber-picocontainer/src/test/java/io/cucumber/picocontainer/annotationconfig/DatabaseConnectionProvider.java new file mode 100644 index 0000000000..36eaf1e884 --- /dev/null +++ b/cucumber-picocontainer/src/test/java/io/cucumber/picocontainer/annotationconfig/DatabaseConnectionProvider.java @@ -0,0 +1,13 @@ +package io.cucumber.picocontainer.annotationconfig; + +import org.picocontainer.injectors.ProviderAdapter; + +import java.sql.Connection; + +public class DatabaseConnectionProvider extends ProviderAdapter { + + public Connection provide() { + throw new UnsupportedOperationException("Intentionally not supported to detect any premature injection."); + } + +} diff --git a/cucumber-picocontainer/src/test/java/io/cucumber/picocontainer/annotationconfig/ExamplePicoConfiguration.java b/cucumber-picocontainer/src/test/java/io/cucumber/picocontainer/annotationconfig/ExamplePicoConfiguration.java new file mode 100644 index 0000000000..6493a03088 --- /dev/null +++ b/cucumber-picocontainer/src/test/java/io/cucumber/picocontainer/annotationconfig/ExamplePicoConfiguration.java @@ -0,0 +1,18 @@ +package io.cucumber.picocontainer.annotationconfig; + +import io.cucumber.picocontainer.CucumberPicoProvider; +import org.picocontainer.injectors.Provider; + +import java.net.URL; + +@CucumberPicoProvider(providers = { URLConnectionProvider.class, DatabaseConnectionProvider.class }) +public class ExamplePicoConfiguration { + + @CucumberPicoProvider + public static class NestedUrlProvider implements Provider { + public URL provide() { + throw new UnsupportedOperationException("Intentionally not supported to detect any premature injection."); + } + } + +} diff --git a/cucumber-picocontainer/src/test/java/io/cucumber/picocontainer/annotationconfig/URLConnectionProvider.java b/cucumber-picocontainer/src/test/java/io/cucumber/picocontainer/annotationconfig/URLConnectionProvider.java new file mode 100644 index 0000000000..d408a4b9d6 --- /dev/null +++ b/cucumber-picocontainer/src/test/java/io/cucumber/picocontainer/annotationconfig/URLConnectionProvider.java @@ -0,0 +1,14 @@ +package io.cucumber.picocontainer.annotationconfig; + +import org.picocontainer.injectors.Provider; + +import java.net.HttpURLConnection; +import java.net.URL; + +public class URLConnectionProvider implements Provider { + + public HttpURLConnection provide(URL url) { + throw new UnsupportedOperationException("Intentionally not supported to detect any premature injection."); + } + +} diff --git a/cucumber-picocontainer/src/test/java/io/cucumber/picocontainer/annotationconfig/UrlToUriProvider.java b/cucumber-picocontainer/src/test/java/io/cucumber/picocontainer/annotationconfig/UrlToUriProvider.java new file mode 100644 index 0000000000..4aa6c27dfa --- /dev/null +++ b/cucumber-picocontainer/src/test/java/io/cucumber/picocontainer/annotationconfig/UrlToUriProvider.java @@ -0,0 +1,16 @@ +package io.cucumber.picocontainer.annotationconfig; + +import io.cucumber.picocontainer.CucumberPicoProvider; +import org.picocontainer.injectors.Provider; + +import java.net.URI; +import java.net.URL; + +@CucumberPicoProvider +public class UrlToUriProvider implements Provider { + + public URI provide(URL url) { + throw new UnsupportedOperationException("Intentionally not supported to detect any premature injection."); + } + +}