From c6e9ca989eef8f60de854ebeb308077adfdf2d21 Mon Sep 17 00:00:00 2001 From: Auri Date: Wed, 29 Jul 2026 12:51:34 +0100 Subject: [PATCH] Add provides API (#1853) Add provides API Co-authored-by: Shane Freeder --- .../plugin/ap/PluginAnnotationProcessor.java | 10 ++++ .../ap/SerializedPluginDescription.java | 16 +++++-- .../velocitypowered/api/plugin/Plugin.java | 8 ++++ .../api/plugin/PluginDescription.java | 10 ++++ .../velocitypowered/proxy/VelocityServer.java | 3 +- .../proxy/plugin/VelocityPluginManager.java | 46 +++++++++++++++---- .../loader/VelocityPluginDescription.java | 14 +++++- .../plugin/loader/java/JavaPluginLoader.java | 10 ++++ .../java/JavaVelocityPluginDescription.java | 6 +-- ...avaVelocityPluginDescriptionCandidate.java | 6 +-- .../plugin/util/PluginDependencyUtils.java | 19 ++++++-- .../util/PluginDependencyUtilsTest.java | 32 ++++++++++++- 12 files changed, 154 insertions(+), 26 deletions(-) diff --git a/api/src/ap/java/com/velocitypowered/api/plugin/ap/PluginAnnotationProcessor.java b/api/src/ap/java/com/velocitypowered/api/plugin/ap/PluginAnnotationProcessor.java index b44f2847..572ed03c 100644 --- a/api/src/ap/java/com/velocitypowered/api/plugin/ap/PluginAnnotationProcessor.java +++ b/api/src/ap/java/com/velocitypowered/api/plugin/ap/PluginAnnotationProcessor.java @@ -97,6 +97,16 @@ public class PluginAnnotationProcessor extends AbstractProcessor { } } + for (String provided : plugin.provides()) { + if (!SerializedPluginDescription.ID_PATTERN.matcher(provided).matches()) { + environment.getMessager().printMessage(Diagnostic.Kind.ERROR, + "Invalid provided ID '" + provided + "' for plugin " + qualifiedName + + ". IDs must start alphabetically, have lowercase alphanumeric characters, and " + + "can contain dashes or underscores."); + return false; + } + } + // All good, generate the velocity-plugin.json. SerializedPluginDescription description = SerializedPluginDescription .from(plugin, qualifiedName.toString()); diff --git a/api/src/ap/java/com/velocitypowered/api/plugin/ap/SerializedPluginDescription.java b/api/src/ap/java/com/velocitypowered/api/plugin/ap/SerializedPluginDescription.java index b712d896..cdcb53b5 100644 --- a/api/src/ap/java/com/velocitypowered/api/plugin/ap/SerializedPluginDescription.java +++ b/api/src/ap/java/com/velocitypowered/api/plugin/ap/SerializedPluginDescription.java @@ -35,11 +35,12 @@ public final class SerializedPluginDescription { private final @Nullable String url; private final @Nullable List authors; private final @Nullable List dependencies; + private final @Nullable List provides; private final String main; private SerializedPluginDescription(String id, String name, String version, String description, String url, - List authors, List dependencies, String main) { + List authors, List dependencies, List provides, String main) { Preconditions.checkNotNull(id, "id"); Preconditions.checkArgument(ID_PATTERN.matcher(id).matches(), "id is not valid"); this.id = id; @@ -50,6 +51,7 @@ public final class SerializedPluginDescription { this.authors = authors == null || authors.isEmpty() ? ImmutableList.of() : authors; this.dependencies = dependencies == null || dependencies.isEmpty() ? ImmutableList.of() : dependencies; + this.provides = provides == null || provides.isEmpty() ? ImmutableList.of() : provides; this.main = Preconditions.checkNotNull(main, "main"); } @@ -61,7 +63,9 @@ public final class SerializedPluginDescription { return new SerializedPluginDescription(plugin.id(), plugin.name(), plugin.version(), plugin.description(), plugin.url(), Arrays.stream(plugin.authors()).filter(author -> !author.isEmpty()) - .collect(Collectors.toList()), dependencies, qualifiedName); + .collect(Collectors.toList()), dependencies, + Arrays.stream(plugin.provides()).filter(provided -> !provided.isEmpty()) + .collect(Collectors.toList()), qualifiedName); } public String getId() { @@ -92,6 +96,10 @@ public final class SerializedPluginDescription { return dependencies == null ? ImmutableList.of() : dependencies; } + public List getProvides() { + return provides == null ? ImmutableList.of() : provides; + } + public String getMain() { return main; } @@ -112,12 +120,13 @@ public final class SerializedPluginDescription { && Objects.equals(url, that.url) && Objects.equals(authors, that.authors) && Objects.equals(dependencies, that.dependencies) + && Objects.equals(provides, that.provides) && Objects.equals(main, that.main); } @Override public int hashCode() { - return Objects.hash(id, name, version, description, url, authors, dependencies); + return Objects.hash(id, name, version, description, url, authors, dependencies, provides); } @Override @@ -130,6 +139,7 @@ public final class SerializedPluginDescription { + ", url='" + url + '\'' + ", authors=" + authors + ", dependencies=" + dependencies + + ", provides=" + provides + ", main='" + main + '\'' + '}'; } diff --git a/api/src/main/java/com/velocitypowered/api/plugin/Plugin.java b/api/src/main/java/com/velocitypowered/api/plugin/Plugin.java index d3b458ca..d6c8a4d5 100644 --- a/api/src/main/java/com/velocitypowered/api/plugin/Plugin.java +++ b/api/src/main/java/com/velocitypowered/api/plugin/Plugin.java @@ -72,4 +72,12 @@ public @interface Plugin { * @return the plugin dependencies */ Dependency[] dependencies() default {}; + + /** + * The plugin IDs this plugin "provides" for. Each ID must match + * {@link SerializedPluginDescription#ID_PATTERN_STRING}. + * + * @return the provided IDs + */ + String[] provides() default {}; } diff --git a/api/src/main/java/com/velocitypowered/api/plugin/PluginDescription.java b/api/src/main/java/com/velocitypowered/api/plugin/PluginDescription.java index 540b54ea..67e9b9c6 100644 --- a/api/src/main/java/com/velocitypowered/api/plugin/PluginDescription.java +++ b/api/src/main/java/com/velocitypowered/api/plugin/PluginDescription.java @@ -100,6 +100,16 @@ public interface PluginDescription { return Optional.empty(); } + /** + * Gets a {@link Collection} of the provided IDs of the {@link Plugin} within this container. + * + * @return the provided plugins IDs, can be empty + * @see Plugin#provides() + */ + default Collection getProvidedIds() { + return ImmutableSet.of(); + } + /** * Returns the source the plugin was loaded from. * diff --git a/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java b/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java index 83e443e9..7c027dd6 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java @@ -221,7 +221,8 @@ public class VelocityServer implements ProxyServer, ForwardingAudience { PluginDescription description = new VelocityPluginDescription( "velocity", version.getName(), version.getVersion(), "The Velocity proxy", version.getName().equals("Velocity") ? VELOCITY_URL : null, - ImmutableList.of(version.getVendor()), Collections.emptyList(), null); + ImmutableList.of(version.getVendor()), Collections.emptyList(), + Collections.emptyList(), null); VelocityPluginContainer container = new VelocityPluginContainer(description); container.setInstance(VelocityVirtualPlugin.INSTANCE); return container; diff --git a/proxy/src/main/java/com/velocitypowered/proxy/plugin/VelocityPluginManager.java b/proxy/src/main/java/com/velocitypowered/proxy/plugin/VelocityPluginManager.java index 6bd0e008..aacbdc49 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/plugin/VelocityPluginManager.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/plugin/VelocityPluginManager.java @@ -46,10 +46,12 @@ import java.util.Collections; import java.util.HashMap; import java.util.IdentityHashMap; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Set; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -62,6 +64,7 @@ public class VelocityPluginManager implements PluginManager { private final Map pluginsById = new LinkedHashMap<>(); private final Map pluginInstances = new IdentityHashMap<>(); + private final Set plugins = new LinkedHashSet<>(); private final VelocityServer server; public VelocityPluginManager(VelocityServer server) { @@ -74,7 +77,9 @@ public class VelocityPluginManager implements PluginManager { * @param plugin the plugin to register */ public void registerPlugin(PluginContainer plugin) { + plugins.add(plugin); pluginsById.put(plugin.getDescription().getId(), plugin); + plugin.getDescription().getProvidedIds().forEach(id -> pluginsById.put(id, plugin)); Optional instance = plugin.getInstance(); instance.ifPresent(o -> pluginInstances.put(o, plugin)); } @@ -100,16 +105,34 @@ public class VelocityPluginManager implements PluginManager { try { PluginDescription candidate = loader.loadCandidate(path); - // If we found a duplicate candidate (with the same ID), don't load it. - PluginDescription maybeExistingCandidate = foundCandidates.putIfAbsent( - candidate.getId(), candidate); + // A plugin claims its own ID plus every ID it provides. If any of those are already + // claimed by another candidate, don't load this one. + List claimedIds = new ArrayList<>(candidate.getProvidedIds().size() + 1); + claimedIds.add(candidate.getId()); + claimedIds.addAll(candidate.getProvidedIds()); - if (maybeExistingCandidate != null) { - logger.error("Refusing to load plugin at path {} since we already " - + "loaded a plugin with the same ID {} from {}", + PluginDescription conflict = null; + String conflictingId = null; + for (String id : claimedIds) { + PluginDescription existing = foundCandidates.get(id); + if (existing != null) { + conflict = existing; + conflictingId = id; + break; + } + } + + if (conflict != null) { + logger.error("Refusing to load plugin at path {} since ID {} was already " + + "claimed by a plugin loaded from {}", candidate.getSource().map(Objects::toString).orElse(""), - candidate.getId(), - maybeExistingCandidate.getSource().map(Objects::toString).orElse("")); + conflictingId, + conflict.getSource().map(Objects::toString).orElse("")); + continue; + } + + for (String id : claimedIds) { + foundCandidates.put(id, candidate); } } catch (Throwable e) { logger.error("Unable to load plugin {}", path, e); @@ -122,8 +145,10 @@ public class VelocityPluginManager implements PluginManager { return; } + // foundCandidates indexes each candidate under its ID and any provided IDs, so dedupe before + // sorting to avoid loading a plugin more than once. List sortedPlugins = PluginDependencyUtils.sortCandidates( - new ArrayList<>(foundCandidates.values())); + new ArrayList<>(new LinkedHashSet<>(foundCandidates.values()))); Map loadedCandidates = new HashMap<>(); Map pluginContainers = new LinkedHashMap<>(); @@ -144,6 +169,7 @@ public class VelocityPluginManager implements PluginManager { VelocityPluginContainer container = new VelocityPluginContainer(realPlugin); pluginContainers.put(container, loader.createModule(container)); loadedCandidates.put(realPlugin.getId(), realPlugin); + realPlugin.getProvidedIds().forEach(id -> loadedCandidates.putIfAbsent(id, realPlugin)); } catch (Throwable e) { logger.error("Can't create module for plugin {}", candidate.getId(), e); } @@ -201,7 +227,7 @@ public class VelocityPluginManager implements PluginManager { @Override public Collection getPlugins() { - return Collections.unmodifiableCollection(pluginsById.values()); + return Collections.unmodifiableCollection(plugins); } @Override diff --git a/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/VelocityPluginDescription.java b/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/VelocityPluginDescription.java index cde909ef..1a545ded 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/VelocityPluginDescription.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/VelocityPluginDescription.java @@ -21,6 +21,7 @@ import static com.google.common.base.Preconditions.checkNotNull; import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; import com.google.common.collect.Maps; import com.velocitypowered.api.plugin.PluginDescription; import com.velocitypowered.api.plugin.meta.PluginDependency; @@ -43,6 +44,7 @@ public class VelocityPluginDescription implements PluginDescription { private final @Nullable String url; private final List authors; private final Map dependencies; + private final Collection providedIds; private final Path source; /** @@ -55,11 +57,13 @@ public class VelocityPluginDescription implements PluginDescription { * @param url the website for the plugin * @param authors the authors of this plugin * @param dependencies the dependencies for this plugin + * @param providedIds the IDs this plugin provides for * @param source the original source for the plugin */ public VelocityPluginDescription(String id, @Nullable String name, @Nullable String version, @Nullable String description, @Nullable String url, - @Nullable List authors, Collection dependencies, Path source) { + @Nullable List authors, Collection dependencies, + @Nullable Collection providedIds, Path source) { this.id = checkNotNull(id, "id"); this.name = Strings.emptyToNull(name); this.version = Strings.emptyToNull(version); @@ -67,6 +71,8 @@ public class VelocityPluginDescription implements PluginDescription { this.url = Strings.emptyToNull(url); this.authors = authors == null ? ImmutableList.of() : ImmutableList.copyOf(authors); this.dependencies = Maps.uniqueIndex(dependencies, d -> d == null ? null : d.getId()); + this.providedIds = + providedIds == null ? ImmutableSet.of() : ImmutableSet.copyOf(providedIds); this.source = source; } @@ -110,6 +116,11 @@ public class VelocityPluginDescription implements PluginDescription { return Optional.ofNullable(dependencies.get(id)); } + @Override + public Collection getProvidedIds() { + return providedIds; + } + @Override public Optional getSource() { return Optional.ofNullable(source); @@ -125,6 +136,7 @@ public class VelocityPluginDescription implements PluginDescription { + ", url='" + url + '\'' + ", authors=" + authors + ", dependencies=" + dependencies + + ", providedIds=" + providedIds + ", source=" + source + '}'; } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/java/JavaPluginLoader.java b/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/java/JavaPluginLoader.java index 5b69e49a..cbc4b57f 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/java/JavaPluginLoader.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/java/JavaPluginLoader.java @@ -78,6 +78,14 @@ public class JavaPluginLoader implements PluginLoader { } } + for (String providedId : pd.getProvides()) { + if (!SerializedPluginDescription.ID_PATTERN.matcher(providedId).matches()) { + throw new InvalidPluginException( + "Provided ID '" + providedId + "' for plugin '" + pd.getId() + "' is invalid." + ); + } + } + return createCandidateDescription(pd, source); } @@ -181,6 +189,7 @@ public class JavaPluginLoader implements PluginLoader { description.getUrl(), description.getAuthors(), dependencies, + description.getProvides(), source, description.getMain() ); @@ -197,6 +206,7 @@ public class JavaPluginLoader implements PluginLoader { description.getUrl().orElse(null), description.getAuthors(), description.getDependencies(), + description.getProvidedIds(), description.getSource().orElse(null), mainClass ); diff --git a/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/java/JavaVelocityPluginDescription.java b/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/java/JavaVelocityPluginDescription.java index dfc7c89c..d85be85b 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/java/JavaVelocityPluginDescription.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/java/JavaVelocityPluginDescription.java @@ -32,9 +32,9 @@ class JavaVelocityPluginDescription extends VelocityPluginDescription { JavaVelocityPluginDescription(String id, @Nullable String name, @Nullable String version, @Nullable String description, @Nullable String url, - @Nullable List authors, Collection dependencies, Path source, - Class mainClass) { - super(id, name, version, description, url, authors, dependencies, source); + @Nullable List authors, Collection dependencies, + @Nullable Collection providedIds, Path source, Class mainClass) { + super(id, name, version, description, url, authors, dependencies, providedIds, source); this.mainClass = checkNotNull(mainClass); } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/java/JavaVelocityPluginDescriptionCandidate.java b/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/java/JavaVelocityPluginDescriptionCandidate.java index fb7d9dea..1de30f4d 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/java/JavaVelocityPluginDescriptionCandidate.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/java/JavaVelocityPluginDescriptionCandidate.java @@ -32,9 +32,9 @@ class JavaVelocityPluginDescriptionCandidate extends VelocityPluginDescription { JavaVelocityPluginDescriptionCandidate(String id, @Nullable String name, @Nullable String version, @Nullable String description, @Nullable String url, - @Nullable List authors, Collection dependencies, Path source, - String mainClass) { - super(id, name, version, description, url, authors, dependencies, source); + @Nullable List authors, Collection dependencies, + @Nullable Collection providedIds, Path source, String mainClass) { + super(id, name, version, description, url, authors, dependencies, providedIds, source); this.mainClass = checkNotNull(mainClass); } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/plugin/util/PluginDependencyUtils.java b/proxy/src/main/java/com/velocitypowered/proxy/plugin/util/PluginDependencyUtils.java index ed2395fa..cc1026a0 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/plugin/util/PluginDependencyUtils.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/plugin/util/PluginDependencyUtils.java @@ -17,7 +17,6 @@ package com.velocitypowered.proxy.plugin.util; -import com.google.common.collect.Maps; import com.google.common.graph.Graph; import com.google.common.graph.GraphBuilder; import com.google.common.graph.MutableGraph; @@ -60,8 +59,19 @@ public class PluginDependencyUtils { .allowsSelfLoops(false) .expectedNodeCount(sortedCandidates.size()) .build(); - Map candidateMap = Maps.uniqueIndex(sortedCandidates, - PluginDescription::getId); + + // Index candidates by their own ID and any IDs they provide, so a dependency can be satisfied + // by a plugin that provides that ID. Real IDs take precedence over provided IDs, and the first + // provider of a given ID wins; upstream loading rejects such conflicts before we get here. + Map candidateMap = new HashMap<>(); + for (PluginDescription description : sortedCandidates) { + candidateMap.putIfAbsent(description.getId(), description); + } + for (PluginDescription description : sortedCandidates) { + for (String provided : description.getProvidedIds()) { + candidateMap.putIfAbsent(provided, description); + } + } for (PluginDescription description : sortedCandidates) { graph.addNode(description); @@ -69,7 +79,8 @@ public class PluginDependencyUtils { for (PluginDependency dependency : description.getDependencies()) { PluginDescription in = candidateMap.get(dependency.getId()); - if (in != null) { + // Guard against self-loops: a plugin may name an ID it itself provides. + if (in != null && !in.equals(description)) { graph.putEdge(description, in); } } diff --git a/proxy/src/test/java/com/velocitypowered/proxy/plugin/util/PluginDependencyUtilsTest.java b/proxy/src/test/java/com/velocitypowered/proxy/plugin/util/PluginDependencyUtilsTest.java index 3716f85e..4abc3a2d 100644 --- a/proxy/src/test/java/com/velocitypowered/proxy/plugin/util/PluginDependencyUtilsTest.java +++ b/proxy/src/test/java/com/velocitypowered/proxy/plugin/util/PluginDependencyUtilsTest.java @@ -44,6 +44,15 @@ class PluginDependencyUtilsTest { private static final PluginDescription CIRCULAR_DEPENDENCY_2 = testDescription("oval", new PluginDependency("circle", "", false)); + // "provider" is loaded from a real ID but provides the virtual ID "some-api"; "consumer" and + // "zdependent" depend on it only through that provided ID / a chain that reaches it. + private static final PluginDescription PROVIDES_API = providingDescription("provider", + ImmutableList.of("some-api")); + private static final PluginDescription DEPENDS_ON_PROVIDED = providingDescription("consumer", + ImmutableList.of(), new PluginDependency("some-api", null, false)); + private static final PluginDescription DEPENDS_ON_CONSUMER = testDescription("zdependent", + new PluginDependency("consumer", null, false)); + @Test void sortCandidatesTrivial() throws Exception { List descriptionList = new ArrayList<>(); @@ -96,10 +105,31 @@ class PluginDependencyUtilsTest { assertThrows(IllegalStateException.class, () -> PluginDependencyUtils.sortCandidates(descs)); } + @Test + void sortCandidatesResolvesProvidedDependency() throws Exception { + List plugins = ImmutableList.of(DEPENDS_ON_PROVIDED, PROVIDES_API); + List expected = ImmutableList.of(PROVIDES_API, DEPENDS_ON_PROVIDED); + assertEquals(expected, PluginDependencyUtils.sortCandidates(plugins)); + } + + @Test + void sortCandidatesResolvesTransitiveProvidedDependency() throws Exception { + List plugins = ImmutableList.of(DEPENDS_ON_CONSUMER, DEPENDS_ON_PROVIDED, + PROVIDES_API); + List expected = ImmutableList.of(PROVIDES_API, DEPENDS_ON_PROVIDED, + DEPENDS_ON_CONSUMER); + assertEquals(expected, PluginDependencyUtils.sortCandidates(plugins)); + } + private static PluginDescription testDescription(String id, PluginDependency... dependencies) { + return providingDescription(id, ImmutableList.of(), dependencies); + } + + private static PluginDescription providingDescription(String id, List provides, + PluginDependency... dependencies) { return new VelocityPluginDescription( id, "tuxed", "0.1", null, null, ImmutableList.of(), - ImmutableList.copyOf(dependencies), null + ImmutableList.copyOf(dependencies), provides, null ); } }