Add provides API (#1853)

Add provides API

Co-authored-by: Shane Freeder <theboyetronic@gmail.com>
This commit is contained in:
Auri
2026-07-29 12:51:34 +01:00
committed by GitHub
co-authored by Shane Freeder
parent d30f1d9a74
commit c6e9ca989e
12 changed files with 154 additions and 26 deletions
@@ -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. // All good, generate the velocity-plugin.json.
SerializedPluginDescription description = SerializedPluginDescription SerializedPluginDescription description = SerializedPluginDescription
.from(plugin, qualifiedName.toString()); .from(plugin, qualifiedName.toString());
@@ -35,11 +35,12 @@ public final class SerializedPluginDescription {
private final @Nullable String url; private final @Nullable String url;
private final @Nullable List<String> authors; private final @Nullable List<String> authors;
private final @Nullable List<Dependency> dependencies; private final @Nullable List<Dependency> dependencies;
private final @Nullable List<String> provides;
private final String main; private final String main;
private SerializedPluginDescription(String id, String name, String version, String description, private SerializedPluginDescription(String id, String name, String version, String description,
String url, String url,
List<String> authors, List<Dependency> dependencies, String main) { List<String> authors, List<Dependency> dependencies, List<String> provides, String main) {
Preconditions.checkNotNull(id, "id"); Preconditions.checkNotNull(id, "id");
Preconditions.checkArgument(ID_PATTERN.matcher(id).matches(), "id is not valid"); Preconditions.checkArgument(ID_PATTERN.matcher(id).matches(), "id is not valid");
this.id = id; this.id = id;
@@ -50,6 +51,7 @@ public final class SerializedPluginDescription {
this.authors = authors == null || authors.isEmpty() ? ImmutableList.of() : authors; this.authors = authors == null || authors.isEmpty() ? ImmutableList.of() : authors;
this.dependencies = this.dependencies =
dependencies == null || dependencies.isEmpty() ? ImmutableList.of() : dependencies; dependencies == null || dependencies.isEmpty() ? ImmutableList.of() : dependencies;
this.provides = provides == null || provides.isEmpty() ? ImmutableList.of() : provides;
this.main = Preconditions.checkNotNull(main, "main"); this.main = Preconditions.checkNotNull(main, "main");
} }
@@ -61,7 +63,9 @@ public final class SerializedPluginDescription {
return new SerializedPluginDescription(plugin.id(), plugin.name(), plugin.version(), return new SerializedPluginDescription(plugin.id(), plugin.name(), plugin.version(),
plugin.description(), plugin.url(), plugin.description(), plugin.url(),
Arrays.stream(plugin.authors()).filter(author -> !author.isEmpty()) 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() { public String getId() {
@@ -92,6 +96,10 @@ public final class SerializedPluginDescription {
return dependencies == null ? ImmutableList.of() : dependencies; return dependencies == null ? ImmutableList.of() : dependencies;
} }
public List<String> getProvides() {
return provides == null ? ImmutableList.of() : provides;
}
public String getMain() { public String getMain() {
return main; return main;
} }
@@ -112,12 +120,13 @@ public final class SerializedPluginDescription {
&& Objects.equals(url, that.url) && Objects.equals(url, that.url)
&& Objects.equals(authors, that.authors) && Objects.equals(authors, that.authors)
&& Objects.equals(dependencies, that.dependencies) && Objects.equals(dependencies, that.dependencies)
&& Objects.equals(provides, that.provides)
&& Objects.equals(main, that.main); && Objects.equals(main, that.main);
} }
@Override @Override
public int hashCode() { 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 @Override
@@ -130,6 +139,7 @@ public final class SerializedPluginDescription {
+ ", url='" + url + '\'' + ", url='" + url + '\''
+ ", authors=" + authors + ", authors=" + authors
+ ", dependencies=" + dependencies + ", dependencies=" + dependencies
+ ", provides=" + provides
+ ", main='" + main + '\'' + ", main='" + main + '\''
+ '}'; + '}';
} }
@@ -72,4 +72,12 @@ public @interface Plugin {
* @return the plugin dependencies * @return the plugin dependencies
*/ */
Dependency[] dependencies() default {}; 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 {};
} }
@@ -100,6 +100,16 @@ public interface PluginDescription {
return Optional.empty(); 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<String> getProvidedIds() {
return ImmutableSet.of();
}
/** /**
* Returns the source the plugin was loaded from. * Returns the source the plugin was loaded from.
* *
@@ -221,7 +221,8 @@ public class VelocityServer implements ProxyServer, ForwardingAudience {
PluginDescription description = new VelocityPluginDescription( PluginDescription description = new VelocityPluginDescription(
"velocity", version.getName(), version.getVersion(), "The Velocity proxy", "velocity", version.getName(), version.getVersion(), "The Velocity proxy",
version.getName().equals("Velocity") ? VELOCITY_URL : null, 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); VelocityPluginContainer container = new VelocityPluginContainer(description);
container.setInstance(VelocityVirtualPlugin.INSTANCE); container.setInstance(VelocityVirtualPlugin.INSTANCE);
return container; return container;
@@ -46,10 +46,12 @@ import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.IdentityHashMap; import java.util.IdentityHashMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
@@ -62,6 +64,7 @@ public class VelocityPluginManager implements PluginManager {
private final Map<String, PluginContainer> pluginsById = new LinkedHashMap<>(); private final Map<String, PluginContainer> pluginsById = new LinkedHashMap<>();
private final Map<Object, PluginContainer> pluginInstances = new IdentityHashMap<>(); private final Map<Object, PluginContainer> pluginInstances = new IdentityHashMap<>();
private final Set<PluginContainer> plugins = new LinkedHashSet<>();
private final VelocityServer server; private final VelocityServer server;
public VelocityPluginManager(VelocityServer server) { public VelocityPluginManager(VelocityServer server) {
@@ -74,7 +77,9 @@ public class VelocityPluginManager implements PluginManager {
* @param plugin the plugin to register * @param plugin the plugin to register
*/ */
public void registerPlugin(PluginContainer plugin) { public void registerPlugin(PluginContainer plugin) {
plugins.add(plugin);
pluginsById.put(plugin.getDescription().getId(), plugin); pluginsById.put(plugin.getDescription().getId(), plugin);
plugin.getDescription().getProvidedIds().forEach(id -> pluginsById.put(id, plugin));
Optional<?> instance = plugin.getInstance(); Optional<?> instance = plugin.getInstance();
instance.ifPresent(o -> pluginInstances.put(o, plugin)); instance.ifPresent(o -> pluginInstances.put(o, plugin));
} }
@@ -100,16 +105,34 @@ public class VelocityPluginManager implements PluginManager {
try { try {
PluginDescription candidate = loader.loadCandidate(path); PluginDescription candidate = loader.loadCandidate(path);
// If we found a duplicate candidate (with the same ID), don't load it. // A plugin claims its own ID plus every ID it provides. If any of those are already
PluginDescription maybeExistingCandidate = foundCandidates.putIfAbsent( // claimed by another candidate, don't load this one.
candidate.getId(), candidate); List<String> claimedIds = new ArrayList<>(candidate.getProvidedIds().size() + 1);
claimedIds.add(candidate.getId());
claimedIds.addAll(candidate.getProvidedIds());
if (maybeExistingCandidate != null) { PluginDescription conflict = null;
logger.error("Refusing to load plugin at path {} since we already " String conflictingId = null;
+ "loaded a plugin with the same ID {} from {}", 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("<UNKNOWN>"), candidate.getSource().map(Objects::toString).orElse("<UNKNOWN>"),
candidate.getId(), conflictingId,
maybeExistingCandidate.getSource().map(Objects::toString).orElse("<UNKNOWN>")); conflict.getSource().map(Objects::toString).orElse("<UNKNOWN>"));
continue;
}
for (String id : claimedIds) {
foundCandidates.put(id, candidate);
} }
} catch (Throwable e) { } catch (Throwable e) {
logger.error("Unable to load plugin {}", path, e); logger.error("Unable to load plugin {}", path, e);
@@ -122,8 +145,10 @@ public class VelocityPluginManager implements PluginManager {
return; 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<PluginDescription> sortedPlugins = PluginDependencyUtils.sortCandidates( List<PluginDescription> sortedPlugins = PluginDependencyUtils.sortCandidates(
new ArrayList<>(foundCandidates.values())); new ArrayList<>(new LinkedHashSet<>(foundCandidates.values())));
Map<String, PluginDescription> loadedCandidates = new HashMap<>(); Map<String, PluginDescription> loadedCandidates = new HashMap<>();
Map<PluginContainer, Module> pluginContainers = new LinkedHashMap<>(); Map<PluginContainer, Module> pluginContainers = new LinkedHashMap<>();
@@ -144,6 +169,7 @@ public class VelocityPluginManager implements PluginManager {
VelocityPluginContainer container = new VelocityPluginContainer(realPlugin); VelocityPluginContainer container = new VelocityPluginContainer(realPlugin);
pluginContainers.put(container, loader.createModule(container)); pluginContainers.put(container, loader.createModule(container));
loadedCandidates.put(realPlugin.getId(), realPlugin); loadedCandidates.put(realPlugin.getId(), realPlugin);
realPlugin.getProvidedIds().forEach(id -> loadedCandidates.putIfAbsent(id, realPlugin));
} catch (Throwable e) { } catch (Throwable e) {
logger.error("Can't create module for plugin {}", candidate.getId(), e); logger.error("Can't create module for plugin {}", candidate.getId(), e);
} }
@@ -201,7 +227,7 @@ public class VelocityPluginManager implements PluginManager {
@Override @Override
public Collection<PluginContainer> getPlugins() { public Collection<PluginContainer> getPlugins() {
return Collections.unmodifiableCollection(pluginsById.values()); return Collections.unmodifiableCollection(plugins);
} }
@Override @Override
@@ -21,6 +21,7 @@ import static com.google.common.base.Preconditions.checkNotNull;
import com.google.common.base.Strings; import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Maps; import com.google.common.collect.Maps;
import com.velocitypowered.api.plugin.PluginDescription; import com.velocitypowered.api.plugin.PluginDescription;
import com.velocitypowered.api.plugin.meta.PluginDependency; import com.velocitypowered.api.plugin.meta.PluginDependency;
@@ -43,6 +44,7 @@ public class VelocityPluginDescription implements PluginDescription {
private final @Nullable String url; private final @Nullable String url;
private final List<String> authors; private final List<String> authors;
private final Map<String, PluginDependency> dependencies; private final Map<String, PluginDependency> dependencies;
private final Collection<String> providedIds;
private final Path source; private final Path source;
/** /**
@@ -55,11 +57,13 @@ public class VelocityPluginDescription implements PluginDescription {
* @param url the website for the plugin * @param url the website for the plugin
* @param authors the authors of this plugin * @param authors the authors of this plugin
* @param dependencies the dependencies for 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 * @param source the original source for the plugin
*/ */
public VelocityPluginDescription(String id, @Nullable String name, @Nullable String version, public VelocityPluginDescription(String id, @Nullable String name, @Nullable String version,
@Nullable String description, @Nullable String url, @Nullable String description, @Nullable String url,
@Nullable List<String> authors, Collection<PluginDependency> dependencies, Path source) { @Nullable List<String> authors, Collection<PluginDependency> dependencies,
@Nullable Collection<String> providedIds, Path source) {
this.id = checkNotNull(id, "id"); this.id = checkNotNull(id, "id");
this.name = Strings.emptyToNull(name); this.name = Strings.emptyToNull(name);
this.version = Strings.emptyToNull(version); this.version = Strings.emptyToNull(version);
@@ -67,6 +71,8 @@ public class VelocityPluginDescription implements PluginDescription {
this.url = Strings.emptyToNull(url); this.url = Strings.emptyToNull(url);
this.authors = authors == null ? ImmutableList.of() : ImmutableList.copyOf(authors); this.authors = authors == null ? ImmutableList.of() : ImmutableList.copyOf(authors);
this.dependencies = Maps.uniqueIndex(dependencies, d -> d == null ? null : d.getId()); this.dependencies = Maps.uniqueIndex(dependencies, d -> d == null ? null : d.getId());
this.providedIds =
providedIds == null ? ImmutableSet.of() : ImmutableSet.copyOf(providedIds);
this.source = source; this.source = source;
} }
@@ -110,6 +116,11 @@ public class VelocityPluginDescription implements PluginDescription {
return Optional.ofNullable(dependencies.get(id)); return Optional.ofNullable(dependencies.get(id));
} }
@Override
public Collection<String> getProvidedIds() {
return providedIds;
}
@Override @Override
public Optional<Path> getSource() { public Optional<Path> getSource() {
return Optional.ofNullable(source); return Optional.ofNullable(source);
@@ -125,6 +136,7 @@ public class VelocityPluginDescription implements PluginDescription {
+ ", url='" + url + '\'' + ", url='" + url + '\''
+ ", authors=" + authors + ", authors=" + authors
+ ", dependencies=" + dependencies + ", dependencies=" + dependencies
+ ", providedIds=" + providedIds
+ ", source=" + source + ", source=" + source
+ '}'; + '}';
} }
@@ -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); return createCandidateDescription(pd, source);
} }
@@ -181,6 +189,7 @@ public class JavaPluginLoader implements PluginLoader {
description.getUrl(), description.getUrl(),
description.getAuthors(), description.getAuthors(),
dependencies, dependencies,
description.getProvides(),
source, source,
description.getMain() description.getMain()
); );
@@ -197,6 +206,7 @@ public class JavaPluginLoader implements PluginLoader {
description.getUrl().orElse(null), description.getUrl().orElse(null),
description.getAuthors(), description.getAuthors(),
description.getDependencies(), description.getDependencies(),
description.getProvidedIds(),
description.getSource().orElse(null), description.getSource().orElse(null),
mainClass mainClass
); );
@@ -32,9 +32,9 @@ class JavaVelocityPluginDescription extends VelocityPluginDescription {
JavaVelocityPluginDescription(String id, @Nullable String name, @Nullable String version, JavaVelocityPluginDescription(String id, @Nullable String name, @Nullable String version,
@Nullable String description, @Nullable String url, @Nullable String description, @Nullable String url,
@Nullable List<String> authors, Collection<PluginDependency> dependencies, Path source, @Nullable List<String> authors, Collection<PluginDependency> dependencies,
Class<?> mainClass) { @Nullable Collection<String> providedIds, Path source, Class<?> mainClass) {
super(id, name, version, description, url, authors, dependencies, source); super(id, name, version, description, url, authors, dependencies, providedIds, source);
this.mainClass = checkNotNull(mainClass); this.mainClass = checkNotNull(mainClass);
} }
@@ -32,9 +32,9 @@ class JavaVelocityPluginDescriptionCandidate extends VelocityPluginDescription {
JavaVelocityPluginDescriptionCandidate(String id, @Nullable String name, @Nullable String version, JavaVelocityPluginDescriptionCandidate(String id, @Nullable String name, @Nullable String version,
@Nullable String description, @Nullable String url, @Nullable String description, @Nullable String url,
@Nullable List<String> authors, Collection<PluginDependency> dependencies, Path source, @Nullable List<String> authors, Collection<PluginDependency> dependencies,
String mainClass) { @Nullable Collection<String> providedIds, Path source, String mainClass) {
super(id, name, version, description, url, authors, dependencies, source); super(id, name, version, description, url, authors, dependencies, providedIds, source);
this.mainClass = checkNotNull(mainClass); this.mainClass = checkNotNull(mainClass);
} }
@@ -17,7 +17,6 @@
package com.velocitypowered.proxy.plugin.util; package com.velocitypowered.proxy.plugin.util;
import com.google.common.collect.Maps;
import com.google.common.graph.Graph; import com.google.common.graph.Graph;
import com.google.common.graph.GraphBuilder; import com.google.common.graph.GraphBuilder;
import com.google.common.graph.MutableGraph; import com.google.common.graph.MutableGraph;
@@ -60,8 +59,19 @@ public class PluginDependencyUtils {
.allowsSelfLoops(false) .allowsSelfLoops(false)
.expectedNodeCount(sortedCandidates.size()) .expectedNodeCount(sortedCandidates.size())
.build(); .build();
Map<String, PluginDescription> 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<String, PluginDescription> 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) { for (PluginDescription description : sortedCandidates) {
graph.addNode(description); graph.addNode(description);
@@ -69,7 +79,8 @@ public class PluginDependencyUtils {
for (PluginDependency dependency : description.getDependencies()) { for (PluginDependency dependency : description.getDependencies()) {
PluginDescription in = candidateMap.get(dependency.getId()); 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); graph.putEdge(description, in);
} }
} }
@@ -44,6 +44,15 @@ class PluginDependencyUtilsTest {
private static final PluginDescription CIRCULAR_DEPENDENCY_2 = testDescription("oval", private static final PluginDescription CIRCULAR_DEPENDENCY_2 = testDescription("oval",
new PluginDependency("circle", "", false)); 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 @Test
void sortCandidatesTrivial() throws Exception { void sortCandidatesTrivial() throws Exception {
List<PluginDescription> descriptionList = new ArrayList<>(); List<PluginDescription> descriptionList = new ArrayList<>();
@@ -96,10 +105,31 @@ class PluginDependencyUtilsTest {
assertThrows(IllegalStateException.class, () -> PluginDependencyUtils.sortCandidates(descs)); assertThrows(IllegalStateException.class, () -> PluginDependencyUtils.sortCandidates(descs));
} }
@Test
void sortCandidatesResolvesProvidedDependency() throws Exception {
List<PluginDescription> plugins = ImmutableList.of(DEPENDS_ON_PROVIDED, PROVIDES_API);
List<PluginDescription> expected = ImmutableList.of(PROVIDES_API, DEPENDS_ON_PROVIDED);
assertEquals(expected, PluginDependencyUtils.sortCandidates(plugins));
}
@Test
void sortCandidatesResolvesTransitiveProvidedDependency() throws Exception {
List<PluginDescription> plugins = ImmutableList.of(DEPENDS_ON_CONSUMER, DEPENDS_ON_PROVIDED,
PROVIDES_API);
List<PluginDescription> expected = ImmutableList.of(PROVIDES_API, DEPENDS_ON_PROVIDED,
DEPENDS_ON_CONSUMER);
assertEquals(expected, PluginDependencyUtils.sortCandidates(plugins));
}
private static PluginDescription testDescription(String id, PluginDependency... dependencies) { private static PluginDescription testDescription(String id, PluginDependency... dependencies) {
return providingDescription(id, ImmutableList.of(), dependencies);
}
private static PluginDescription providingDescription(String id, List<String> provides,
PluginDependency... dependencies) {
return new VelocityPluginDescription( return new VelocityPluginDescription(
id, "tuxed", "0.1", null, null, ImmutableList.of(), id, "tuxed", "0.1", null, null, ImmutableList.of(),
ImmutableList.copyOf(dependencies), null ImmutableList.copyOf(dependencies), provides, null
); );
} }
} }