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..1cc367ebdb --- /dev/null +++ b/cucumber-picocontainer/src/main/java/io/cucumber/picocontainer/CucumberPicoProvider.java @@ -0,0 +1,61 @@ +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");
+ *     }
+ * }
+ * 
+ *

+ * Notes: + *

+ */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@API(status = API.Status.EXPERIMENTAL) +public @interface CucumberPicoProvider { +} 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..422c88a5ec --- /dev/null +++ b/cucumber-picocontainer/src/main/java/io/cucumber/picocontainer/PicoBackend.java @@ -0,0 +1,52 @@ +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; + +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)) + .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..4f9713c1a0 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; @@ -16,6 +20,7 @@ public final class PicoFactory implements ObjectFactory { private final Set> classes = new HashSet<>(); + private final Set> providers = new HashSet<>(); private MutablePicoContainer pico; private static boolean isInstantiable(Class clazz) { @@ -31,34 +36,103 @@ public void start() { .withCaching() .withLifecycle() .build(); + Set> providedClasses = new HashSet<>(); + for (Class clazz : providers) { + ProviderAdapter adapter = adapterForProviderClass(clazz); + pico.addAdapter(adapter); + providedClasses.add(adapter.getComponentImplementation()); + } for (Class clazz : classes) { - pico.addComponent(clazz); + // do not add classes that are already provided (otherwise this + // causes exceptional situations, e.g. PicoCompositionException + // with message "Duplicate Keys not allowed. Duplicate for + // 'class XXX'") + if (!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 hasCucumberPicoProvider(Class clazz) { + return clazz.isAnnotationPresent(CucumberPicoProvider.class); + } + + 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 = 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) { - if (isInstantiable(clazz) && classes.add(clazz)) { - addConstructorDependencies(clazz); + if (hasCucumberPicoProvider(clazz)) { + providers.add(checkProperPicoProvider(clazz)); + } else { + if (isInstantiable(clazz) && classes.add(clazz)) { + addConstructorDependencies(clazz); + } } return true; } + private static boolean hasDefaultConstructor(Class clazz) { + for (Constructor constructor : clazz.getDeclaredConstructors()) { + if (constructor.getParameterCount() == 0) { + return true; + } + } + return false; + } + + @SuppressWarnings("unchecked") + private static Class checkProperPicoProvider(Class clazz) { + if (!isProvider(clazz) || !isInstantiable(clazz) || !hasDefaultConstructor(clazz)) { + throw new CucumberBackendException(String.format("" + + "Glue class %1$s was annotated with @CucumberPicoProvider; marking it as a candidate for declaring a" + + + "PicoContainer Provider instance. Please ensure that all of the following requirements are satisfied:\n" + + + "1) the class implements org.picocontainer.injectors.Provider\n" + + "2) the class is public\n" + + "3) the class is not abstract\n" + + "4) the class provides a default constructor\n" + + "5) if nested, the class is static.", + clazz.getName())); + } + return (Class) clazz; + } + @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..88ee4b1b7e --- /dev/null +++ b/cucumber-picocontainer/src/test/java/io/cucumber/picocontainer/PicoBackendTest.java @@ -0,0 +1,85 @@ +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.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(ExamplePicoConfiguration.NestedUrlConnectionProvider.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(ExamplePicoConfiguration.NestedUrlProvider.class); + verify(factory, times(1)).addClass(ExamplePicoConfiguration.NestedUrlConnectionProvider.class); + verify(factory, times(1)).addClass(UrlToUriProvider.class); + verify(factory, times(1)).addClass(DatabaseConnectionProvider.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..034df01299 --- /dev/null +++ b/cucumber-picocontainer/src/test/java/io/cucumber/picocontainer/annotationconfig/DatabaseConnectionProvider.java @@ -0,0 +1,15 @@ +package io.cucumber.picocontainer.annotationconfig; + +import io.cucumber.picocontainer.CucumberPicoProvider; +import org.picocontainer.injectors.ProviderAdapter; + +import java.sql.Connection; + +@CucumberPicoProvider +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..89780000f3 --- /dev/null +++ b/cucumber-picocontainer/src/test/java/io/cucumber/picocontainer/annotationconfig/ExamplePicoConfiguration.java @@ -0,0 +1,25 @@ +package io.cucumber.picocontainer.annotationconfig; + +import io.cucumber.picocontainer.CucumberPicoProvider; +import org.picocontainer.injectors.Provider; + +import java.net.HttpURLConnection; +import java.net.URL; + +public class ExamplePicoConfiguration { + + @CucumberPicoProvider + public static class NestedUrlProvider implements Provider { + public URL provide() { + throw new UnsupportedOperationException("Intentionally not supported to detect any premature injection."); + } + } + + @CucumberPicoProvider + public static class NestedUrlConnectionProvider 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."); + } + +}