Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions core/src/main/java/io/gdcc/spi/core/loader/LoaderHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -436,6 +437,52 @@ static PluginValidationResult<SourcedDescriptor> verifyProviderApiLevels(List<So
);
}

/**
* Loads and instantiates a plugin class from the specified URL using the provided class loader and verifies
* that it implements the expected plugin interface or superclass.
*
* @param <T> 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 extends Plugin> T loadPluginClass(
String className,
Class<T> 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<? extends T> implClass = rawClass.asSubclass(pluginClass);
return implClass.getDeclaredConstructor().newInstance();
}
}


/**
* Converts a {@link SourcedDescriptor} and a plugin instance into a {@link PluginDescriptor}.
Expand Down
90 changes: 58 additions & 32 deletions core/src/main/java/io/gdcc/spi/core/loader/PluginLoader.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -43,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.
* </p>
* <p>
* 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.
* </p>
*
* @param <T> the type of plugin to load, constrained to implement the {@link Plugin} interface
*/
Expand Down Expand Up @@ -130,6 +137,7 @@ static void validatePluginBaseClass(Class<?> pluginClass) {
* <p>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).
* </p>
*
* <p>For each discovered plugin, its {@link Plugin#identity()} must be non-null and non-blank;
* otherwise, it is skipped and an error is recorded.
Expand All @@ -140,6 +148,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 <i>will not</i> 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<PluginHandle<T>> load(Path pluginJarsLocation) {

Expand Down Expand Up @@ -251,8 +264,11 @@ List<SourcedDescriptor> preloadPlugins(Set<Path> 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);
Expand All @@ -266,7 +282,7 @@ List<SourcedDescriptor> preloadPlugins(Set<Path> sources, SourceScanner scanner)
var finalResults = PluginValidationResult.merge(
collisionResult,
implementationResult,
serviceProviderResult,
//serviceProviderResult,
apiLevelResult,
providerLevelsResult
);
Expand Down Expand Up @@ -307,7 +323,8 @@ List<SourcedDescriptor> preloadPlugins(Set<Path> 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
Expand All @@ -319,37 +336,46 @@ List<PluginHandle<T>> load(List<SourcedDescriptor> descriptors, Map<Path,URL[]>
List<LoaderProblem> sourceProblems = new ArrayList<>();
List<PluginHandle<T>> 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<T> 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
);

// 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;
}

// 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) {
// 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
Expand Down
148 changes: 148 additions & 0 deletions core/src/main/java/io/gdcc/spi/core/loader/package-info.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/**
* Provides the runtime plugin loading facilities for the SPI-based plugin system.
*
* <h2>Overview</h2>
*
* <p>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.</p>
*
* <p>The loader is intentionally validation-first:</p>
* <ol>
* <li>It discovers candidate plugin sources from a given location.</li>
* <li>It scans descriptor metadata from those sources.</li>
* <li>It validates the metadata before plugin classes are instantiated.</li>
* <li>It loads only the descriptors that passed validation, or those explicitly allowed
* by configuration as warning-level cases.</li>
* <li>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}.</li>
* </ol>
*
* <p>This design keeps failure handling predictable and avoids loading plugin classes
* unnecessarily when metadata already shows that a plugin is incompatible.</p>
*
* <h2>How the loader works</h2>
*
* <p>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.</p>
*
* <p>Calling {@link io.gdcc.spi.core.loader.PluginLoader#load(java.nio.file.Path)} performs two broad phases:</p>
*
* <h3>1. Preload and validation</h3>
*
* <p>During preloading, the loader scans the configured sources and reads plugin descriptors.
* It then validates, among other things:</p>
* <ul>
* <li>class name collisions between plugins,</li>
* <li>class name collisions with classes already present in core,</li>
* <li>whether a descriptor matches the requested base contract,</li>
* <li>base contract API level compatibility,</li>
* <li>required provider compatibility, and</li>
* <li>identity uniqueness rules, depending on configuration.</li>
* </ul>
*
* <p>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.</p>
*
* <h3>2. Class loading and instantiation</h3>
*
* <p>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.</p>
*
* <h2>Applying configuration options</h2>
*
* <p>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.</p>
*
* <p>Typical configuration concerns include:</p>
* <ul>
* <li>whether sources must contain only plugins matching the requested base contract,</li>
* <li>whether mixed sources should emit warnings,</li>
* <li>whether compatibility problems should abort loading, and</li>
* <li>whether ambiguous or duplicated plugin identities should be rejected.</li>
* </ul>
*
* <p>Because configuration instances are immutable, they are easy to reuse, share, and test.</p>
*
* <h3>Example: customizing loader behavior</h3>
*
* <pre>{@code
* LoaderConfiguration configuration = LoaderConfiguration.defaults()
* .withEnforceSingleSourceMatchingPluginsOnly(false)
* .withEmitWarningsOnMultiPluginSource(true)
* .withAbortOnCompatibilityProblems(false);
*
* PluginLoader<MyPluginContract> loader =
* new PluginLoader<>(MyPluginContract.class, configuration);
* }</pre>
*
* <h2>What to expect</h2>
*
* <ul>
* <li><strong>Early validation:</strong> many incompatibilities are detected from metadata
* before plugin classes are instantiated.</li>
* <li><strong>Aggregated diagnostics:</strong> failures are reported as collections of
* {@link io.gdcc.spi.core.loader.LoaderProblem problems} through {@link io.gdcc.spi.core.loader.LoaderException#getProblems()}.</li>
* <li><strong>Contract-focused loading:</strong> each loader instance targets one base
* plugin contract at a time.</li>
* <li><strong>Structured runtime results:</strong> successful loads are represented by
* {@link io.gdcc.spi.core.loader.PluginHandle} values rather than raw plugin instances alone.</li>
* <li><strong>Configurable strictness:</strong> callers can choose between fail-fast and
* more permissive behavior depending on operational needs.</li>
* </ul>
*
* <h2>What not to expect</h2>
*
* <ul>
* <li><strong>No hot reload:</strong> 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.</li>
* <li><strong>No legacy-plugin compatibility guarantee:</strong> the loader operates on the
* descriptor-based plugin model and does not aim to support older plugins that do not
* participate in that model.</li>
* <li><strong>No cross-loader identity coordination:</strong> identity uniqueness is checked
* within the scope of a loader run, not globally across every possible plugin source in
* an application.</li>
* <li><strong>No substitute for packaging discipline:</strong> plugin authors are still
* expected to provide correct metadata, compatible API levels, and non-conflicting
* classes and identities.</li>
* </ul>
*
* <h2>Usage examples</h2>
*
* <h3>Example 1: Loading plugins with default settings</h3>
*
* <pre>{@code
* try {
* PluginLoader<MyPluginContract> loader = new PluginLoader<>(MyPluginContract.class);
* List<PluginHandle<MyPluginContract>> 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());
* }
* }</pre>
*
* <h3>Example 2: Using an explicit parent class loader</h3>
*
* <pre>{@code
* ClassLoader parent = Thread.currentThread().getContextClassLoader();
* PluginLoader<MyPluginContract> loader = new PluginLoader<>(MyPluginContract.class, parent);
* }</pre>
*
* <p>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.</p>
*/
package io.gdcc.spi.core.loader;
Loading