diff --git a/CHANGELOG.md b/CHANGELOG.md index f6c4e4e495..7203855f00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - [Core] Upload Cucumber Reports with Gzip encoding ([#3115](https://github.com/cucumber/cucumber-jvm/pull/3115)) - [Java] Add `Scenario.getLanguage()` to return the current language ([#3124](https://github.com/cucumber/cucumber-jvm/pull/3124) Stefan Gasterstädt) +- [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.32.0] - 2025-11-21 ### Changed diff --git a/cucumber-picocontainer/README.md b/cucumber-picocontainer/README.md index 430f88ac72..951476d994 100644 --- a/cucumber-picocontainer/README.md +++ b/cucumber-picocontainer/README.md @@ -123,3 +123,29 @@ 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 and/or +`ProviderAdapter`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.PicoConfiguration; +import org.picocontainer.injectors.ProviderAdapter; + +@PicoConfiguration(providerAdapters = { ExamplePicoConfiguration.DatabaseConnectionProvider.class }) +public class ExamplePicoConfiguration { + + public static class DatabaseConnectionProvider extends ProviderAdapter { + 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 800b8977cf..7fe595cf7e 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/PicoBackend.java b/cucumber-picocontainer/src/main/java/io/cucumber/picocontainer/PicoBackend.java new file mode 100644 index 0000000000..71000e6038 --- /dev/null +++ b/cucumber-picocontainer/src/main/java/io/cucumber/picocontainer/PicoBackend.java @@ -0,0 +1,57 @@ +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 static io.cucumber.core.resource.ClasspathSupport.CLASSPATH_SCHEME; +import static java.util.Arrays.stream; + +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(PicoConfiguration.class)) + .distinct() + .forEach(picoConfig -> { + PicoConfiguration configuration = picoConfig.getAnnotation(PicoConfiguration.class); + stream(configuration.providers()).forEach(container::addClass); + stream(configuration.providerAdapters()).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/PicoConfiguration.java b/cucumber-picocontainer/src/main/java/io/cucumber/picocontainer/PicoConfiguration.java new file mode 100644 index 0000000000..a42c13580a --- /dev/null +++ b/cucumber-picocontainer/src/main/java/io/cucumber/picocontainer/PicoConfiguration.java @@ -0,0 +1,79 @@ +package io.cucumber.picocontainer; + +import org.apiguardian.api.API; +import org.picocontainer.MutablePicoContainer; +import org.picocontainer.injectors.Provider; +import org.picocontainer.injectors.ProviderAdapter; + +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 + * configuration. At the moment this covers: + *
    + *
  • a list of classes conforming the PicoContainer's {@link Provider} + * interface,
  • + *
  • a list of classes conforming the PicoContainer's {@link ProviderAdapter} + * interface.
  • + *
+ *

+ * An example (ancillary containing the specific ProviderAdapter as nested + * class) is: + * + *

+ * package some.example;
+ *
+ * import java.sql.*;
+ * import io.cucumber.picocontainer.PicoConfiguration;
+ * import org.picocontainer.injectors.ProviderAdapter;
+ *
+ * @PicoConfiguration(providerAdapters = { MyPicoConfiguration.DatabaseConnectionProvider.class })
+ * public class MyPicoConfiguration {
+ *
+ *     public static class DatabaseConnectionProvider extends ProviderAdapter {
+ *         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");
+ *         }
+ *     }
+ *
+ * }
+ * 
+ *

+ * Notes: + *

    + *
  • Currently, there is no limitation to the number of + * {@link PicoConfiguration} annotations. All of these annotations will be + * considered when preparing the {@link org.picocontainer.PicoContainer + * PicoContainer}.
  • + *
  • If there is no {@link PicoConfiguration} annotation at all then (beside + * the basic preparation) no additional PicoContainer preparation will be + * done.
  • + *
  • Cucumber PicoContainer uses PicoContainer's {@link MutablePicoContainer} + * internally. Doing so, all {@link #providers() Providers} will be added by + * {@link MutablePicoContainer#addAdapter(org.picocontainer.ComponentAdapter) + * MutablePicoContainer#addAdapter(new ProviderAdapter(provider))} and all + * {@link #providerAdapters() ProviderAdapters} will be added by + * {@link MutablePicoContainer#addAdapter(org.picocontainer.ComponentAdapter) + * MutablePicoContainer#addAdapter(adapter)}.
  • + *
  • For each class there can be only one + * {@link Provider}/{@link ProviderAdapter}. Otherwise an according exception + * will be thrown (e.g. {@code PicoCompositionException} with message "Duplicate + * Keys not allowed ..."
  • + *
+ */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@API(status = API.Status.EXPERIMENTAL) +public @interface PicoConfiguration { + + Class[] providers() default {}; + + Class[] providerAdapters() default {}; + +} 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..27cb5bd2f8 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,8 +35,29 @@ public void start() { .withCaching() .withLifecycle() .build(); + Set> providers = new HashSet<>(); + Set> providedClasses = new HashSet<>(); for (Class clazz : classes) { - pico.addComponent(clazz); + if (isProviderAdapter(clazz)) { + providers.add(clazz); + Class providedClass = addProviderAdapter(clazz); + providedClasses.add(providedClass); + } else if (isProvider(clazz)) { + providers.add(clazz); + Class providedClass = addProvider(clazz); + providedClasses.add(providedClass); + } + } + for (Class clazz : classes) { + // do not add the classes that represent a picocontainer + // ProviderAdapter/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, @@ -45,9 +70,40 @@ public void start() { pico.start(); } + private boolean isProviderAdapter(Class clazz) { + return ProviderAdapter.class.isAssignableFrom(clazz); + } + + private Class addProviderAdapter(Class clazz) { + try { + ProviderAdapter adapter = (ProviderAdapter) clazz.getDeclaredConstructor().newInstance(); + pico.addAdapter(adapter); + return adapter.getComponentImplementation(); + } catch (ReflectiveOperationException | IllegalArgumentException | SecurityException | PicoException e) { + throw new CucumberBackendException(e.getMessage(), e); + } + } + + private boolean isProvider(Class clazz) { + return Provider.class.isAssignableFrom(clazz); + } + + private Class addProvider(Class clazz) { + try { + Provider provider = (Provider) clazz.getDeclaredConstructor().newInstance(); + ProviderAdapter adapter = new ProviderAdapter(provider); + pico.addAdapter(adapter); + return adapter.getComponentImplementation(); + } 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(); } 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..12c0e5e7dd --- /dev/null +++ b/cucumber-picocontainer/src/test/java/io/cucumber/picocontainer/PicoBackendTest.java @@ -0,0 +1,71 @@ +package io.cucumber.picocontainer; + +import io.cucumber.core.backend.Glue; +import io.cucumber.core.backend.ObjectFactory; +import io.cucumber.picocontainer.annotationconfig.ConnectionProvider; +import io.cucumber.picocontainer.annotationconfig.DatabaseConnectionProvider; +import io.cucumber.picocontainer.annotationconfig.ExamplePicoConfiguration; +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_provider_classes() { + backend.loadGlue(glue, singletonList(URI.create("classpath:io/cucumber/picocontainer/annotationconfig"))); + backend.buildWorld(); + verify(factory).addClass(ConnectionProvider.class); + } + + @Test + void adds_provideradapter_classes() { + backend.loadGlue(glue, singletonList(URI.create("classpath:io/cucumber/picocontainer/annotationconfig"))); + backend.buildWorld(); + verify(factory).addClass(DatabaseConnectionProvider.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(ConnectionProvider.class); + verify(factory, times(1)).addClass(DatabaseConnectionProvider.class); + } + +} diff --git a/cucumber-picocontainer/src/test/java/io/cucumber/picocontainer/annotationconfig/ConnectionProvider.java b/cucumber-picocontainer/src/test/java/io/cucumber/picocontainer/annotationconfig/ConnectionProvider.java new file mode 100644 index 0000000000..12d31b3bf6 --- /dev/null +++ b/cucumber-picocontainer/src/test/java/io/cucumber/picocontainer/annotationconfig/ConnectionProvider.java @@ -0,0 +1,13 @@ +package io.cucumber.picocontainer.annotationconfig; + +import org.picocontainer.injectors.Provider; + +import java.net.HttpURLConnection; + +public class ConnectionProvider implements Provider { + + public HttpURLConnection provide() { + throw new UnsupportedOperationException("Intentionally not supported to detect any premature injection."); + } + +} 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..2aab9bffd4 --- /dev/null +++ b/cucumber-picocontainer/src/test/java/io/cucumber/picocontainer/annotationconfig/ExamplePicoConfiguration.java @@ -0,0 +1,7 @@ +package io.cucumber.picocontainer.annotationconfig; + +import io.cucumber.picocontainer.PicoConfiguration; + +@PicoConfiguration(providers = ConnectionProvider.class, providerAdapters = DatabaseConnectionProvider.class) +public class ExamplePicoConfiguration { +}