From b7617836264fc408d944e963160b620515070cca Mon Sep 17 00:00:00 2001 From: Oliver Bertuch Date: Tue, 5 May 2026 21:10:10 +0200 Subject: [PATCH 1/9] test(meta): fix flaky assertion message in `PluginContractProcessorTest` For some reason different JDKs seem to generate slightly different messages for non-repeatable annotations ("type" vs "interface"). Removing the word fixes the flakyness. --- .../io/gdcc/spi/meta/processor/PluginContractProcessorTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/meta/src/test/java/io/gdcc/spi/meta/processor/PluginContractProcessorTest.java b/meta/src/test/java/io/gdcc/spi/meta/processor/PluginContractProcessorTest.java index 14f668f..fd9d6ff 100644 --- a/meta/src/test/java/io/gdcc/spi/meta/processor/PluginContractProcessorTest.java +++ b/meta/src/test/java/io/gdcc/spi/meta/processor/PluginContractProcessorTest.java @@ -3103,7 +3103,7 @@ public interface DuplicateAnnotatedPlugin extends Plugin { )); assertFalse(result.success(), "Compilation should fail"); - assertDiagnosticContains(result, Diagnostic.Kind.ERROR, "PluginContract is not a repeatable annotation type"); + assertDiagnosticContains(result, Diagnostic.Kind.ERROR, "PluginContract is not a repeatable annotation"); } } From 17c09bd41005d58c3546f1da7ecf83dc3f1090b0 Mon Sep 17 00:00:00 2001 From: Oliver Bertuch Date: Tue, 5 May 2026 23:05:11 +0200 Subject: [PATCH 2/9] test(core): add support for core annotation processors in `LoaderTestEnvironment` - Extend `LoaderTestEnvironment.Builder` with `addCoreProcessor` to register core annotation processors. - Update `build` method to include core processors during compilation. - Simplify plugin processor setup by directly chaining `withProcessors`. --- .../core/compiler/LoaderTestEnvironment.java | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/core/src/test/java/io/gdcc/spi/core/compiler/LoaderTestEnvironment.java b/core/src/test/java/io/gdcc/spi/core/compiler/LoaderTestEnvironment.java index a05ae6d..9323c13 100644 --- a/core/src/test/java/io/gdcc/spi/core/compiler/LoaderTestEnvironment.java +++ b/core/src/test/java/io/gdcc/spi/core/compiler/LoaderTestEnvironment.java @@ -61,9 +61,11 @@ public Path pluginClassesDirectory() { public static final class Builder { private final List coreSources = new ArrayList<>(); + private final List coreProcessors = new ArrayList<>(); private final List pluginSources = new ArrayList<>(); private final List pluginProcessors = new ArrayList<>(); + // TODO: inject this via system property, set by surefire configuration private String release = "17"; private boolean packagePluginAsJar = false; private String pluginJarName = "plugin-under-test.jar"; @@ -80,6 +82,11 @@ public Builder addCoreSource(String relativePath, String content) { this.coreSources.add(TestJavaCompiler.SourceFile.of(relativePath, content)); return this; } + + public Builder addCoreProcessor(Processor processor) { + this.coreProcessors.add(processor); + return this; + } public Builder addPluginSource(String relativePath, String content) { this.pluginSources.add(TestJavaCompiler.SourceFile.of(relativePath, content)); @@ -104,26 +111,21 @@ public Builder withPluginJarName(String pluginJarName) { public LoaderTestEnvironment build() throws IOException { TestCompilation coreCompilation = TestJavaCompiler.builder() .withRelease(release) + .withProcessors(coreProcessors) .build() .compile(coreSources); - + coreCompilation.assertSuccess(); - URLClassLoader coreClassLoader = - coreCompilation.newClassLoader(Thread.currentThread().getContextClassLoader()); - - TestJavaCompiler.Builder pluginCompilerBuilder = TestJavaCompiler.builder() + URLClassLoader coreClassLoader = coreCompilation.newClassLoader(Thread.currentThread().getContextClassLoader()); + + TestCompilation pluginCompilation = TestJavaCompiler.builder() .withRelease(release) - .withClasspathEntry(coreCompilation.classOutputDir()); - - if (!pluginProcessors.isEmpty()) { - pluginCompilerBuilder.withProcessors(pluginProcessors); - } - - TestCompilation pluginCompilation = pluginCompilerBuilder + .withClasspathEntry(coreCompilation.classOutputDir()) + .withProcessors(pluginProcessors) .build() .compile(pluginSources); - + pluginCompilation.assertSuccess(); Path pluginArtifact = packagePluginAsJar From c4d8a5ff666e434a267c921050950baefc7f79fa Mon Sep 17 00:00:00 2001 From: Oliver Bertuch Date: Tue, 5 May 2026 23:05:43 +0200 Subject: [PATCH 3/9] test(core): use parent directory for plugin JAR path in `LoaderTestEnvironment` Update `LoaderTestEnvironment` to use the containing directory of the JAR file when packaging a plugin, ensuring compatibility with full directory scanning, as the plugin loader expects a directory. --- .../java/io/gdcc/spi/core/compiler/LoaderTestEnvironment.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/src/test/java/io/gdcc/spi/core/compiler/LoaderTestEnvironment.java b/core/src/test/java/io/gdcc/spi/core/compiler/LoaderTestEnvironment.java index 9323c13..8f7ebef 100644 --- a/core/src/test/java/io/gdcc/spi/core/compiler/LoaderTestEnvironment.java +++ b/core/src/test/java/io/gdcc/spi/core/compiler/LoaderTestEnvironment.java @@ -129,7 +129,8 @@ public LoaderTestEnvironment build() throws IOException { pluginCompilation.assertSuccess(); Path pluginArtifact = packagePluginAsJar - ? pluginCompilation.createJar(pluginJarName) + // Use the parent = containing dir here, as the loader always scans full directories + ? pluginCompilation.createJar(pluginJarName).getParent() : pluginCompilation.classOutputDir(); return new LoaderTestEnvironment( From 62d8e123ee5a92dfd7ca6f1242cbcf4f65687ab3 Mon Sep 17 00:00:00 2001 From: Oliver Bertuch Date: Tue, 5 May 2026 23:06:21 +0200 Subject: [PATCH 4/9] test(core): add display names to parameterized API level test cases in `PluginLoaderIntegrationTest` - Add `name` attribute to `@ParameterizedTest` to improve test case readability when running with varied core and plugin API levels. --- .../io/gdcc/spi/core/loader/PluginLoaderIntegrationTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/test/java/io/gdcc/spi/core/loader/PluginLoaderIntegrationTest.java b/core/src/test/java/io/gdcc/spi/core/loader/PluginLoaderIntegrationTest.java index 1efc568..0e1ed74 100644 --- a/core/src/test/java/io/gdcc/spi/core/loader/PluginLoaderIntegrationTest.java +++ b/core/src/test/java/io/gdcc/spi/core/loader/PluginLoaderIntegrationTest.java @@ -52,7 +52,7 @@ public String identity() { final String simplePluginClassFile = pluginPackage.replace(".", "/") + "/" + simplePluginClass + ".java"; final String simplePluginCode = pluginCodeTemplate.formatted(pluginPackage, contractPackage, contractClass, simplePluginClass, contractClass); - @ParameterizedTest + @ParameterizedTest(name = "API levels: core={0}, plugin={1}") @CsvSource({"1,2","2,1"}) void rejectsPluginCompiledAgainstDifferentBaseApiLevel(int coreLevel, int pluginLevel) throws Exception { // Given From db54d7822e8737f508c9ff5745badfd8b1146df6 Mon Sep 17 00:00:00 2001 From: Oliver Bertuch Date: Tue, 5 May 2026 23:07:49 +0200 Subject: [PATCH 5/9] test(core): extend basic acceptance test to verify both JAR and directory loading - Replace `@Test` with `@ParameterizedTest` using `@ValueSource` to improve test coverage for both JAR and directory packaging scenarios. - Wrap plugin loading assertions in a `try-catch` block to provide detailed failure information in case of a `LoaderException`. --- .../loader/PluginLoaderIntegrationTest.java | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/core/src/test/java/io/gdcc/spi/core/loader/PluginLoaderIntegrationTest.java b/core/src/test/java/io/gdcc/spi/core/loader/PluginLoaderIntegrationTest.java index 0e1ed74..c75dc97 100644 --- a/core/src/test/java/io/gdcc/spi/core/loader/PluginLoaderIntegrationTest.java +++ b/core/src/test/java/io/gdcc/spi/core/loader/PluginLoaderIntegrationTest.java @@ -6,12 +6,15 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; import java.nio.file.Path; +import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.fail; class PluginLoaderIntegrationTest { @@ -86,8 +89,9 @@ void rejectsPluginCompiledAgainstDifferentBaseApiLevel(int coreLevel, int plugin assertInstanceOf(LoaderProblem.PluginClassApiLevelMismatch.class, ex.getProblems().get(0)); } - @Test - void acceptsPluginCompiledAgainstSameBaseApiLevel() throws Exception { + @ParameterizedTest(name = "Packaging as JAR: {0}") + @ValueSource(booleans = {true, false}) + void acceptsPluginCompiledAgainstSameBaseApiLevel(boolean packageAsJar) throws Exception { // Given int apiLevel = 5; @@ -105,7 +109,7 @@ void acceptsPluginCompiledAgainstSameBaseApiLevel() throws Exception { simplePluginCode ) .addPluginProcessor(new PluginContractProcessor()) - .packagePluginAsJar(false) + .packagePluginAsJar(packageAsJar) .build(); Class pluginContractClass = env.coreClassLoader().loadClass(contractPackage + "." + contractClass); @@ -115,11 +119,15 @@ void acceptsPluginCompiledAgainstSameBaseApiLevel() throws Exception { PluginLoader loader = new PluginLoader<>(typedContract, env.coreClassLoader()); Path pluginLocation = Path.of(env.pluginArtifact().toString()); - // When - var plugins = loader.load(pluginLocation); - - // Then - assertEquals(1, plugins.size()); - assertEquals(pluginPackage + "." + simplePluginClass, plugins.get(0).plugin().getClass().getName()); + try { + // When + var plugins = loader.load(pluginLocation); + + // Then + assertEquals(1, plugins.size()); + assertEquals(pluginPackage + "." + simplePluginClass, plugins.get(0).plugin().getClass().getName()); + } catch (LoaderException e) { + fail("Loader problems detected:\n" + e.getProblems().stream().map(LoaderProblem::message).collect(Collectors.joining(",\n")), e); + } } } From cd7a7f5719f78df82b78fc9c760c5ee3fb5ecc79 Mon Sep 17 00:00:00 2001 From: Oliver Bertuch Date: Tue, 5 May 2026 23:08:48 +0200 Subject: [PATCH 6/9] refactor(core): replace Java SPI ServiceLoader with custom plugin loader - Remove usage of Java's default ServiceLoader for plugin initialization to avoid class loading conflicts. - Introduce `LoaderHelper.loadPluginClass` for custom plugin loading with collision detection and validation. - Update `PluginLoader` to use the custom loader, improving reliability and maintainability. --- .../io/gdcc/spi/core/loader/LoaderHelper.java | 47 ++++++++++++ .../io/gdcc/spi/core/loader/PluginLoader.java | 73 +++++++++++-------- 2 files changed, 89 insertions(+), 31 deletions(-) diff --git a/core/src/main/java/io/gdcc/spi/core/loader/LoaderHelper.java b/core/src/main/java/io/gdcc/spi/core/loader/LoaderHelper.java index 64ea06b..27d9dff 100644 --- a/core/src/main/java/io/gdcc/spi/core/loader/LoaderHelper.java +++ b/core/src/main/java/io/gdcc/spi/core/loader/LoaderHelper.java @@ -12,6 +12,7 @@ import java.lang.reflect.Field; import java.net.MalformedURLException; import java.net.URL; +import java.net.URLClassLoader; import java.nio.file.Path; import java.util.ArrayList; import java.util.HashMap; @@ -436,6 +437,52 @@ static PluginValidationResult verifyProviderApiLevels(List The type of the plugin, which extends the {@code Plugin} interface or class. + * @param className The fully qualified name of the class to load. + * @param pluginClass The expected class or interface that the plugin must extend or implement. + * @param url The URL pointing to the plugin's location (e.g., a jar file). + * @param parent The parent {@code ClassLoader} to use as a fallback during class loading. + * @return An instance of the loaded plugin class, cast to the specified plugin type. + * @throws ReflectiveOperationException If the class cannot be loaded, instantiated, or lacks a no-argument constructor. + * @throws IOException If an I/O error occurs while accessing the plugin's location. + * @throws IllegalStateException If the plugin class is resolved from the parent class loader instead of the provided URL. + * @throws IllegalArgumentException If the loaded class does not implement or extend the specified plugin type. + */ + static T loadPluginClass( + String className, + Class pluginClass, + URL[] url, + ClassLoader parent + ) throws ReflectiveOperationException, IOException { + try (URLClassLoader loader = URLClassLoader.newInstance(url, parent)) { + // Load the class and initialize it right away. + // IMPORTANT: If the same FQCN is on the parent class loader path, the class would be retrieved from there, not the URL! + // As part of preload(), LoaderHelper.verifyNoClassCollisions() already made sure no collision exists. + Class rawClass = Class.forName(className, false, loader); + + // Just to be really sure, verify this is from the plugin, not the core + if (rawClass.getClassLoader() != loader) { + throw new IllegalStateException( + "Plugin class " + className + " resolved from parent instead of plugin source" + ); + } + + // Make sure the class is actually an implementation of the expected class + // (and the plugin descriptor didn't lie about it in preload checks) + if (!pluginClass.isAssignableFrom(rawClass)) { + throw new IllegalArgumentException("Plugin does not implement contract class"); + } + + // Try to get an actual instance of the class with the default noargs constructor + Class implClass = rawClass.asSubclass(pluginClass); + return implClass.getDeclaredConstructor().newInstance(); + } + } + /** * Converts a {@link SourcedDescriptor} and a plugin instance into a {@link PluginDescriptor}. diff --git a/core/src/main/java/io/gdcc/spi/core/loader/PluginLoader.java b/core/src/main/java/io/gdcc/spi/core/loader/PluginLoader.java index edea2cd..a5d5ffd 100644 --- a/core/src/main/java/io/gdcc/spi/core/loader/PluginLoader.java +++ b/core/src/main/java/io/gdcc/spi/core/loader/PluginLoader.java @@ -20,7 +20,6 @@ import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.ServiceConfigurationError; import java.util.ServiceLoader; import java.util.Set; import java.util.regex.PatternSyntaxException; @@ -251,8 +250,11 @@ List preloadPlugins(Set sources, SourceScanner scanner) logger.debug("Scanning for non-implementations results: {}", implementationResult); // 4. Verify that every plugin class has a service loader entry. Remove any affected from the list. - var serviceProviderResult = LoaderHelper.verifyServiceProviderRecords(descriptors); - logger.debug("Scanning for SPI record results: {}", serviceProviderResult); + // 2026-05-05 OB: To avoid class loading conflicts by the Java SPI default ServiceLoader, + // we now use our own, custom loader to initialize plugins. + // This check is no longer necessary but kept if we need to re-introduce it. + //var serviceProviderResult = LoaderHelper.verifyServiceProviderRecords(descriptors); + //logger.debug("Scanning for SPI record results: {}", serviceProviderResult); // 5. Verify that the API level of the plugin matches the core-expected level(s). var apiLevelResult = LoaderHelper.verifyPluginApiLevels(descriptors, this.pluginClass, this.parentClassLoader); @@ -266,7 +268,7 @@ List preloadPlugins(Set sources, SourceScanner scanner) var finalResults = PluginValidationResult.merge( collisionResult, implementationResult, - serviceProviderResult, + //serviceProviderResult, apiLevelResult, providerLevelsResult ); @@ -319,37 +321,46 @@ List> load(List descriptors, Map List sourceProblems = new ArrayList<>(); List> loadedPlugins = new ArrayList<>(); - // Create URLClassLoader for each file and load the plugin - descriptors.forEach(descriptor -> { + // Create URLClassLoader for each file and load the plugin from the location + for (SourcedDescriptor descriptor : descriptors) { URL[] sourceUrl = sources.get(descriptor.sourceLocation()); - try (URLClassLoader classLoader = URLClassLoader.newInstance(sourceUrl, this.parentClassLoader)) { - // Load all plugins that can be found within the source for type T - ServiceLoader loader = ServiceLoader.load(this.pluginClass, classLoader); + String pluginClassName = descriptor.plugin().klass(); + + try { + // Load the plugin from the source + T plugin = LoaderHelper.loadPluginClass( + pluginClassName, + this.pluginClass, + sourceUrl, + this.parentClassLoader + ); - // Iterate over all found plugins and add to the plugin map, including source information - loader.forEach(plugin -> { - String identity = plugin.identity(); - if (identity == null || identity.isBlank()) { - sourceProblems.add(new LoaderProblem.LocationFailure( - descriptor.sourceLocation(), - new IllegalArgumentException(plugin.getClass().getCanonicalName() + "'s identity cannot be null or blank"))); - return; - } - - // Save the plugin and its metadata to the set of already loaded plugins - loadedPlugins.add( - new PluginHandle<>( - LoaderHelper.toPluginDescriptor( - descriptor, - plugin, - this.parentClassLoader), - plugin) - ); - }); - } catch (IOException | NoSuchMethodError | ServiceConfigurationError | UnsupportedClassVersionError e) { + // Check for a valid identity being present, making the plugin identifiable once handed over to core + String identity = plugin.identity(); + if (identity == null || identity.isBlank()) { + sourceProblems.add(new LoaderProblem.LocationFailure( + descriptor.sourceLocation(), + new IllegalArgumentException(pluginClassName + "'s identity cannot be null or blank"))); + continue; + } + + // Save the plugin and its metadata to the set of already loaded plugins + loadedPlugins.add( + new PluginHandle<>( + LoaderHelper.toPluginDescriptor( + descriptor, + plugin, + this.parentClassLoader), + plugin) + ); + } catch (IllegalArgumentException e) { + sourceProblems.add(new LoaderProblem.PluginClassMismatch(pluginClassName, descriptor.sourceLocation(), pluginClass.getName())); + } catch (IllegalStateException e) { + sourceProblems.add(new LoaderProblem.PluginClassNameCollisionWithCore(pluginClassName, descriptor.sourceLocation())); + } catch ( ReflectiveOperationException | IOException e) { sourceProblems.add(new LoaderProblem.LocationFailure(descriptor.sourceLocation(), e)); } - }); + } logger.debug("Loader was able to load {} plugins from {} sources.", loadedPlugins.size(), sources.size()); // Make sure there are no duplicate plugin identities From ca90478605d11bd562ddcf79f0be48ab9f129e0e Mon Sep 17 00:00:00 2001 From: Oliver Bertuch Date: Tue, 5 May 2026 23:09:21 +0200 Subject: [PATCH 7/9] docs(core): improve `PluginLoader` Javadoc with details on identity uniqueness and collision handling - Clarify guarantees of plugin identity and class name uniqueness among loaded plugins. - Add `@apiNote` regarding collision verification responsibility for external plugins. --- .../main/java/io/gdcc/spi/core/loader/PluginLoader.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/io/gdcc/spi/core/loader/PluginLoader.java b/core/src/main/java/io/gdcc/spi/core/loader/PluginLoader.java index a5d5ffd..ffa8207 100644 --- a/core/src/main/java/io/gdcc/spi/core/loader/PluginLoader.java +++ b/core/src/main/java/io/gdcc/spi/core/loader/PluginLoader.java @@ -129,6 +129,7 @@ static void validatePluginBaseClass(Class pluginClass) { *

Loads all plugins of type {@code T} from JAR files located in the specified directory. * Each JAR file is loaded using a dedicated {@link URLClassLoader}, and plugins are * discovered via the Java {@link ServiceLoader} mechanism (META-INF/services/package.plus.service.ClassName file). + *

* *

For each discovered plugin, its {@link Plugin#identity()} must be non-null and non-blank; * otherwise, it is skipped and an error is recorded. @@ -139,6 +140,11 @@ static void validatePluginBaseClass(Class pluginClass) { * @throws LoaderException if one or more errors occur during loading, if no plugins * could be successfully loaded, or if there are any duplicates. * Note: The exception may contain multiple causes, each associated with a specific file or failure point + * @apiNote The loader will not verify that any loaded plugin identities do not collide with identities loaded + * by other loaders from different locations or classpaths. It is up to the calling code to take care of this + * verification, allowing for flexibility how to deal with these potential collisions between plugins in external + * locations and/or ones shipped with the core. The members of this list of plugins returned by this function are + * guaranteed to be unique by identity and class name only among themselves. */ public List> load(Path pluginJarsLocation) { @@ -309,7 +315,8 @@ List preloadPlugins(Set sources, SourceScanner scanner) * The returned map's keys describe the source of each loaded plugin via {@link PluginDescriptor}, * associating the plugin's logical identity, class name, and JAR file location. It is the * caller's responsibility to verify no duplicates (by class name or identity) exist before - * handing the plugins to the core. + * handing the plugins to the core. The members of this list of plugins returned by this function are + * guaranteed to be unique by identity and class name only among themselves. * * @param sources a mapping from (JAR) file paths to their corresponding URLs used for class loading * @return a map from {@link PluginDescriptor} metadata to the corresponding plugin instance From 7fb9b2478367ae2c156722fbaf35dde7b6a05a9a Mon Sep 17 00:00:00 2001 From: Oliver Bertuch Date: Tue, 5 May 2026 23:41:25 +0200 Subject: [PATCH 8/9] docs(core): add package-level Javadoc for `loader` package - Introduce comprehensive documentation for the `io.gdcc.spi.core.loader` package, detailing its responsibility for runtime plugin discovery, validation, and loading. - Highlight key features, such as validation-first design, contract-based loading, and configurable strictness. - Include usage examples and clarify non-supported features like hot reload or legacy plugin compatibility. - Update `PluginLoader` Javadoc with notes on its custom descriptor-based mechanism, emphasizing the transition away from Java SPI. --- .../io/gdcc/spi/core/loader/PluginLoader.java | 8 + .../io/gdcc/spi/core/loader/package-info.java | 148 ++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 core/src/main/java/io/gdcc/spi/core/loader/package-info.java diff --git a/core/src/main/java/io/gdcc/spi/core/loader/PluginLoader.java b/core/src/main/java/io/gdcc/spi/core/loader/PluginLoader.java index ffa8207..f4469ef 100644 --- a/core/src/main/java/io/gdcc/spi/core/loader/PluginLoader.java +++ b/core/src/main/java/io/gdcc/spi/core/loader/PluginLoader.java @@ -42,6 +42,14 @@ * It supports one-time classloading, but no reloading of changed JARs at runtime. * An application restart is required to pick up changes to plugin JARs. *

+ *

+ * It does not use the standard Java SPI {@link ServiceLoader} mechanism and relies solely on the + * plugin descriptor files generated by {@link io.gdcc.spi.meta.annotations.DataversePlugin} annotation processing. + * See {@link io.gdcc.spi.meta.descriptor.DescriptorFormat#DESCRIPTOR_DIRECTORY} for where these are stored + * in Dataverse Plugins. + * As a consequence, this loader will ignore older plugins for Dataverse that rely solely on the ServiceLoader mechanism. + * The Dataverse core may decide when to drop support for loading these. + *

* * @param the type of plugin to load, constrained to implement the {@link Plugin} interface */ diff --git a/core/src/main/java/io/gdcc/spi/core/loader/package-info.java b/core/src/main/java/io/gdcc/spi/core/loader/package-info.java new file mode 100644 index 0000000..743b5fd --- /dev/null +++ b/core/src/main/java/io/gdcc/spi/core/loader/package-info.java @@ -0,0 +1,148 @@ +/** + * Provides the runtime plugin loading facilities for the SPI-based plugin system. + * + *

Overview

+ * + *

This package is responsible for discovering, validating, and instantiating plugins + * for a specific base plugin contract. The central entry point is {@link io.gdcc.spi.core.loader.PluginLoader}, + * which loads plugins from a filesystem location containing plugin artifacts such as JARs + * or exploded directories.

+ * + *

The loader is intentionally validation-first:

+ *
    + *
  1. It discovers candidate plugin sources from a given location.
  2. + *
  3. It scans descriptor metadata from those sources.
  4. + *
  5. It validates the metadata before plugin classes are instantiated.
  6. + *
  7. It loads only the descriptors that passed validation, or those explicitly allowed + * by configuration as warning-level cases.
  8. + *
  9. It returns loaded plugins as {@link io.gdcc.spi.core.loader.PluginHandle} instances, pairing the runtime + * plugin instance with its resolved {@link io.gdcc.spi.meta.descriptor.PluginDescriptor metadata}.
  10. + *
+ * + *

This design keeps failure handling predictable and avoids loading plugin classes + * unnecessarily when metadata already shows that a plugin is incompatible.

+ * + *

How the loader works

+ * + *

A {@link io.gdcc.spi.core.loader.PluginLoader} is created for exactly one base plugin contract. That contract + * must be a valid plugin interface intended to act as the loader's target type.

+ * + *

Calling {@link io.gdcc.spi.core.loader.PluginLoader#load(java.nio.file.Path)} performs two broad phases:

+ * + *

1. Preload and validation

+ * + *

During preloading, the loader scans the configured sources and reads plugin descriptors. + * It then validates, among other things:

+ *
    + *
  • class name collisions between plugins,
  • + *
  • class name collisions with classes already present in core,
  • + *
  • whether a descriptor matches the requested base contract,
  • + *
  • base contract API level compatibility,
  • + *
  • required provider compatibility, and
  • + *
  • identity uniqueness rules, depending on configuration.
  • + *
+ * + *

Validation problems are represented as {@link io.gdcc.spi.core.loader.LoaderProblem} instances. When the active + * {@link io.gdcc.spi.core.loader.LoaderConfiguration} is strict, incompatible plugins cause loading to abort with + * a {@link io.gdcc.spi.core.loader.LoaderException}. In more permissive configurations, some problems may be treated + * as warnings and the loader may continue with the remaining valid plugins.

+ * + *

2. Class loading and instantiation

+ * + *

After successful prevalidation, plugin implementation classes are loaded from their + * corresponding source locations and instantiated. Each successfully loaded plugin is returned + * as a {@link io.gdcc.spi.core.loader.PluginHandle}, which gives access both to the instantiated plugin and to the + * resolved runtime descriptor.

+ * + *

Applying configuration options

+ * + *

Loader behavior is controlled through {@link io.gdcc.spi.core.loader.LoaderConfiguration}. The configuration + * type is immutable. The recommended style is to start from {@link io.gdcc.spi.core.loader.LoaderConfiguration#defaults()} + * and customize only the options that need to change.

+ * + *

Typical configuration concerns include:

+ *
    + *
  • whether sources must contain only plugins matching the requested base contract,
  • + *
  • whether mixed sources should emit warnings,
  • + *
  • whether compatibility problems should abort loading, and
  • + *
  • whether ambiguous or duplicated plugin identities should be rejected.
  • + *
+ * + *

Because configuration instances are immutable, they are easy to reuse, share, and test.

+ * + *

Example: customizing loader behavior

+ * + *
{@code
+ * LoaderConfiguration configuration = LoaderConfiguration.defaults()
+ *     .withEnforceSingleSourceMatchingPluginsOnly(false)
+ *     .withEmitWarningsOnMultiPluginSource(true)
+ *     .withAbortOnCompatibilityProblems(false);
+ *
+ * PluginLoader loader =
+ *     new PluginLoader<>(MyPluginContract.class, configuration);
+ * }
+ * + *

What to expect

+ * + *
    + *
  • Early validation: many incompatibilities are detected from metadata + * before plugin classes are instantiated.
  • + *
  • Aggregated diagnostics: failures are reported as collections of + * {@link io.gdcc.spi.core.loader.LoaderProblem problems} through {@link io.gdcc.spi.core.loader.LoaderException#getProblems()}.
  • + *
  • Contract-focused loading: each loader instance targets one base + * plugin contract at a time.
  • + *
  • Structured runtime results: successful loads are represented by + * {@link io.gdcc.spi.core.loader.PluginHandle} values rather than raw plugin instances alone.
  • + *
  • Configurable strictness: callers can choose between fail-fast and + * more permissive behavior depending on operational needs.
  • + *
+ * + *

What not to expect

+ * + *
    + *
  • No hot reload: this package supports loading, not live reloading of + * changed plugin artifacts. Updated plugins generally require a fresh loading cycle and + * typically an application restart strategy at a higher level.
  • + *
  • No legacy-plugin compatibility guarantee: the loader operates on the + * descriptor-based plugin model and does not aim to support older plugins that do not + * participate in that model.
  • + *
  • No cross-loader identity coordination: identity uniqueness is checked + * within the scope of a loader run, not globally across every possible plugin source in + * an application.
  • + *
  • No substitute for packaging discipline: plugin authors are still + * expected to provide correct metadata, compatible API levels, and non-conflicting + * classes and identities.
  • + *
+ * + *

Usage examples

+ * + *

Example 1: Loading plugins with default settings

+ * + *
{@code
+ * try {
+ *     PluginLoader loader = new PluginLoader<>(MyPluginContract.class);
+ *     List> plugins = loader.load(Path.of("plugins"));
+ *
+ *     // Use or store plugins.
+ *
+ * } catch (LoaderException ex) {
+ *     for (LoaderProblem problem : ex.getProblems()) {
+ *         System.err.println(problem.message());
+ *     }
+ * } catch (IllegalArgumentException ex) {
+ *     System.err.println(ex.getMessage());
+ * }
+ * }
+ * + *

Example 2: Using an explicit parent class loader

+ * + *
{@code
+ * ClassLoader parent = Thread.currentThread().getContextClassLoader();
+ * PluginLoader loader = new PluginLoader<>(MyPluginContract.class, parent);
+ * }
+ * + *

In most applications, callers only need {@link io.gdcc.spi.core.loader.PluginLoader} and + * {@link io.gdcc.spi.core.loader.LoaderConfiguration}. The remaining types in this package primarily support + * diagnostics, runtime result transport, and internal validation mechanics.

+ */ +package io.gdcc.spi.core.loader; \ No newline at end of file From 540065754b8c509d85057e1719527ed5acbe9f9d Mon Sep 17 00:00:00 2001 From: Oliver Bertuch Date: Wed, 6 May 2026 00:09:36 +0200 Subject: [PATCH 9/9] docs(meta): add comprehensive Javadoc for `meta` packages - Introduce package-level documentation for `processor`, `annotations`, `plugin`, and `descriptor` packages. - Detail annotation-processing support, core SPI contracts, and metadata discovery mechanisms. - Provide examples for plugin implementors and SPI authors, emphasizing descriptor-based discovery over legacy approaches. --- .../spi/meta/annotations/package-info.java | 82 +++++++++++-------- .../spi/meta/descriptor/package-info.java | 24 ++++++ .../io/gdcc/spi/meta/plugin/package-info.java | 18 ++++ .../gdcc/spi/meta/processor/package-info.java | 57 +++++++++++++ 4 files changed, 149 insertions(+), 32 deletions(-) create mode 100644 meta/src/main/java/io/gdcc/spi/meta/descriptor/package-info.java create mode 100644 meta/src/main/java/io/gdcc/spi/meta/plugin/package-info.java create mode 100644 meta/src/main/java/io/gdcc/spi/meta/processor/package-info.java diff --git a/meta/src/main/java/io/gdcc/spi/meta/annotations/package-info.java b/meta/src/main/java/io/gdcc/spi/meta/annotations/package-info.java index bac2b22..ea35563 100644 --- a/meta/src/main/java/io/gdcc/spi/meta/annotations/package-info.java +++ b/meta/src/main/java/io/gdcc/spi/meta/annotations/package-info.java @@ -1,33 +1,56 @@ /** - * Annotations used to declare Dataverse plugin contracts, plugin implementations, + * Annotations used to declare Dataverse plugin implementations, plugin contracts, * and required core providers. * - *

This package defines the author-facing SPI model:

- *
    - *
  • a {@linkplain io.gdcc.spi.meta.annotations.PluginContract.Role#BASE base contract} - * is the unique, directly loadable identity of a plugin,
  • - *
  • a {@linkplain io.gdcc.spi.meta.annotations.PluginContract.Role#CAPABILITY capability contract} - * adds optional functionality but is never loaded directly,
  • - *
  • a {@linkplain io.gdcc.spi.meta.annotations.DataversePlugin plugin implementation} - * must implement exactly one base contract and may additionally implement compatible capabilities,
  • - *
  • a {@linkplain io.gdcc.spi.meta.annotations.RequiredProvider required provider} - * declares Dataverse infrastructure contracts needed by a plugin contract.
  • - *
- * - *

Only base contracts are used as plugin loading identities.

- * - *

Contract interfaces must extend {@link io.gdcc.spi.meta.plugin.Plugin}, declare - * {@link io.gdcc.spi.meta.annotations.PluginContract}, and provide a compile-time - * {@code int API_LEVEL} constant. Plugin contracts must not extend other plugin contracts - * (with the single exception of a capability extending a required base contract).

- * - *

Capabilities are attached to a plugin through normal Java interface implementation. - * This allows SPI authors to provide additional methods and default implementations - * without introducing ambiguity into plugin loading. If multiple implemented interfaces - * contribute conflicting default methods, the plugin implementation class must resolve - * that conflict explicitly.

- * - *

Example with extending base contract:

+ *

For plugin implementors

+ * + *

The primary entry point in this package is + * {@link io.gdcc.spi.meta.annotations.DataversePlugin @DataversePlugin}. + * Plugin implementation classes intended for discovery and loading by Dataverse + * should declare this annotation. + * + *

A plugin implementation must implement exactly one + * {@linkplain io.gdcc.spi.meta.annotations.PluginContract.Role#BASE base contract} + * and may additionally implement compatible + * {@linkplain io.gdcc.spi.meta.annotations.PluginContract.Role#CAPABILITY capability contracts}. + * Only the base contract serves as the direct loading identity of the plugin. + * + *

Example: Plugin side

+ *

(The implemented interface are from the example below) + * + *

{@code
+ *  * @DataversePlugin
+ *  * public class Grill implements FooBar, BarBeque {
+ *  *     // no override needed unless another default conflicts
+ *  * }
+ *  * }
+ * + *

For SPI authors and maintainers

+ * + *

SPI contracts are declared with + * {@link io.gdcc.spi.meta.annotations.PluginContract @PluginContract}. + * A base contract defines the unique, directly loadable plugin kind. A capability + * contract defines additional optional behavior and is never loaded directly.

+ * + *

Contract interfaces must extend {@link io.gdcc.spi.meta.plugin.Plugin}, + * declare {@link io.gdcc.spi.meta.annotations.PluginContract}, and provide a + * compile-time {@code int API_LEVEL} constant. Plugin contracts must not extend + * other plugin contracts, except that a capability may extend its required base + * contract.

+ * + *

Required Dataverse infrastructure dependencies are declared with + * {@link io.gdcc.spi.meta.annotations.RequiredProvider @RequiredProvider}, + * which identifies core provider contracts needed by a plugin contract.

+ * + *

Capabilities are attached to a plugin through normal Java interface + * implementation. This allows SPI authors to define additional methods and + * default implementations without introducing ambiguity into plugin loading. + * If multiple implemented interfaces contribute conflicting default methods, + * the plugin implementation class must resolve that conflict explicitly.

+ * + *

Example: Contract side

+ *

A capability extending its required base contract: + * *

{@code
  * @PluginContract(role = PluginContract.Role.BASE)
  * public interface FooBar extends Plugin {
@@ -46,11 +69,6 @@
  *         return "application/bbq";
  *     }
  * }
- *
- * @DataversePlugin
- * public class Grill implements FooBar, BarBeque {
- *     // no override needed unless another default conflicts
- * }
  * }
*/ package io.gdcc.spi.meta.annotations; diff --git a/meta/src/main/java/io/gdcc/spi/meta/descriptor/package-info.java b/meta/src/main/java/io/gdcc/spi/meta/descriptor/package-info.java new file mode 100644 index 0000000..07b33f9 --- /dev/null +++ b/meta/src/main/java/io/gdcc/spi/meta/descriptor/package-info.java @@ -0,0 +1,24 @@ +/** + * Provides descriptor models and utilities for plugin metadata discovery, serialization, and runtime interpretation. + * + *

This package contains: + *

    + *
  • immutable descriptor types representing plugin metadata,
  • + *
  • format utilities for reading and writing descriptor resources, and
  • + *
  • scanning support for locating descriptor definitions in directories and JAR files.
  • + *
+ * + *

The package separates metadata concerns into distinct layers: + *

    + *
  • raw descriptor data represented in a serialized or transport-friendly form,
  • + *
  • source-aware descriptor views that retain origin information, and
  • + *
  • runtime-facing descriptors that use resolved Java types.
  • + *
+ * + *

All descriptor value types are designed to be immutable and safe to share. + * Utility classes in this package provide stateless helper methods for working + * with descriptor files and service metadata. + * + *

Unless otherwise noted, {@code null} values are not permitted. + */ +package io.gdcc.spi.meta.descriptor; \ No newline at end of file diff --git a/meta/src/main/java/io/gdcc/spi/meta/plugin/package-info.java b/meta/src/main/java/io/gdcc/spi/meta/plugin/package-info.java new file mode 100644 index 0000000..2b78e0a --- /dev/null +++ b/meta/src/main/java/io/gdcc/spi/meta/plugin/package-info.java @@ -0,0 +1,18 @@ +/** + * Defines the core SPI contracts for plugins and core-provided services. + * + *

This package contains the foundational marker and contract interfaces used by the Dataverse Plugin System: + *

    + *
  • {@link io.gdcc.spi.meta.plugin.Plugin}, the supertype base contract for any Dataverse Plugin contracts, and
  • + *
  • {@link io.gdcc.spi.meta.plugin.CoreProvider}, the supertype base contract for framework-provided services that plugins may depend on.
  • + *
+ * + *

These types form the most basic public interaction layer between the Dataverse core and community contributed + * plugin implementations. Plugin contracts are intended to be stable, minimal, and easy to implement. + * + *

Contracts and plugin implementations are expected to provide clear, machine-readable identities and to participate in the wider + * plugin runtime through metadata and discovery mechanisms, defined by annotations from {@link io.gdcc.spi.meta.annotations}. + * + *

Unless otherwise noted, {@code null} values are not permitted. + */ +package io.gdcc.spi.meta.plugin; \ No newline at end of file diff --git a/meta/src/main/java/io/gdcc/spi/meta/processor/package-info.java b/meta/src/main/java/io/gdcc/spi/meta/processor/package-info.java new file mode 100644 index 0000000..dcb295f --- /dev/null +++ b/meta/src/main/java/io/gdcc/spi/meta/processor/package-info.java @@ -0,0 +1,57 @@ +/** + * Provides annotation-processing support for generation of Dataverse Plugin Metadata. + * + *

This package contains compile-time infrastructure for validating plugin declarations and producing metadata + * resources consumed by the runtime plugin loading mechanism. + * + *

Overview

+ * + *

The processor in this package connects the declaration model in {@link io.gdcc.spi.meta.annotations}, + * the core SPI contracts in {@link io.gdcc.spi.meta.plugin}, and the descriptor model in {@link io.gdcc.spi.meta.descriptor}. + * + *

It evaluates + * {@link io.gdcc.spi.meta.annotations.DataversePlugin @DataversePlugin}, + * {@link io.gdcc.spi.meta.annotations.PluginContract @PluginContract}, and + * {@link io.gdcc.spi.meta.annotations.RequiredProvider @RequiredProvider} + * declarations on types derived from {@link io.gdcc.spi.meta.plugin.Plugin} and {@link io.gdcc.spi.meta.plugin.CoreProvider}, + * validates their structure, and generates serialized {@link io.gdcc.spi.meta.descriptor.Descriptor descriptor} + * resources in the format defined by {@link io.gdcc.spi.meta.descriptor.DescriptorFormat}. + * + *

Generated metadata

+ * + *

The primary generated output is descriptor metadata under {@value io.gdcc.spi.meta.descriptor.DescriptorFormat#DESCRIPTOR_DIRECTORY}. + * These descriptors are intended to serve as the authoritative source for plugin discovery and compatibility metadata, + * and may later be discovered by {@link io.gdcc.spi.meta.descriptor.DescriptorScanner} and resolved into runtime-facing + * models such as {@link io.gdcc.spi.meta.descriptor.PluginDescriptor} by the plugin loader. + * + *

For compatibility with older runtime environments, the processor may also emit {@code META-INF/services} resources. + * That mechanism is transitional: descriptor-based discovery is the preferred direction, as it avoids several class-loading + * and isolation limitations associated with {@link java.util.ServiceLoader ServiceLoader}-based loading alone. + * + *

Maven configuration

+ * + *

On JDK 22 and newer, annotation processors should be configured explicitly in the build. + * In Maven, this is typically done through the {@code maven-compiler-plugin}, for example: + * + *

{@code
+ * 
+ *   
+ *     
+ *       maven-compiler-plugin
+ *       
+ *         
+ *           
+ *             io.gdcc
+ *             dataverse-spi
+ *             ${spi.version}
+ *           
+ *         
+ *       
+ *     
+ *   
+ * 
+ * }
+ * + *

Types in this package are mainly internal build-time infrastructure rather than part of the public runtime API. + */ +package io.gdcc.spi.meta.processor; \ No newline at end of file