diff --git a/src/main/java/run/halo/app/plugin/BasePlugin.java b/src/main/java/run/halo/app/plugin/BasePlugin.java index fff8e758b..b0476792a 100644 --- a/src/main/java/run/halo/app/plugin/BasePlugin.java +++ b/src/main/java/run/halo/app/plugin/BasePlugin.java @@ -12,7 +12,7 @@ import org.pf4j.PluginWrapper; * @since 2.0.0 */ @Slf4j -public abstract class BasePlugin extends Plugin { +public class BasePlugin extends Plugin { private PluginApplicationContext applicationContext; diff --git a/src/main/java/run/halo/app/plugin/Plugin.java b/src/main/java/run/halo/app/plugin/Plugin.java new file mode 100644 index 000000000..177cdd07d --- /dev/null +++ b/src/main/java/run/halo/app/plugin/Plugin.java @@ -0,0 +1,51 @@ +package run.halo.app.plugin; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.HashMap; +import java.util.Map; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import run.halo.app.extension.AbstractExtension; + +/** + * A custom resource for Plugin. + * + * @author guqing + * @since 2.0.0 + */ +@Data +@ToString(callSuper = true) +@EqualsAndHashCode(callSuper = true) +public class Plugin extends AbstractExtension { + + @Schema(required = true) + private PluginSpec spec; + + @Data + public static class PluginSpec { + + private String displayName; + + private String version; + + private String author; + + private String logo; + + private Map pluginDependencies = new HashMap<>(4); + + private String homepage; + + private String description; + + private String license; + + /** + * SemVer format. + */ + private String requires = "*"; + + private String pluginClass = BasePlugin.class.getName(); + } +} diff --git a/src/main/java/run/halo/app/plugin/YamlPluginDescriptorFinder.java b/src/main/java/run/halo/app/plugin/YamlPluginDescriptorFinder.java new file mode 100644 index 000000000..19affd9d5 --- /dev/null +++ b/src/main/java/run/halo/app/plugin/YamlPluginDescriptorFinder.java @@ -0,0 +1,60 @@ +package run.halo.app.plugin; + +import java.nio.file.Files; +import java.nio.file.Path; +import lombok.extern.slf4j.Slf4j; +import org.pf4j.DefaultPluginDescriptor; +import org.pf4j.PluginDependency; +import org.pf4j.PluginDescriptor; +import org.pf4j.PluginDescriptorFinder; +import org.pf4j.util.FileUtils; + +/** + * Find a plugin descriptor for a plugin path. + * + * @author guqing + * @see DefaultPluginDescriptor + * @since 2.0.0 + */ +@Slf4j +public class YamlPluginDescriptorFinder implements PluginDescriptorFinder { + + private final YamlPluginFinder yamlPluginFinder; + + public YamlPluginDescriptorFinder() { + yamlPluginFinder = new YamlPluginFinder(); + } + + @Override + public boolean isApplicable(Path pluginPath) { + return Files.exists(pluginPath) + && (Files.isDirectory(pluginPath) + || FileUtils.isJarFile(pluginPath)); + } + + @Override + public PluginDescriptor find(Path pluginPath) { + Plugin plugin = yamlPluginFinder.find(pluginPath); + return convert(plugin); + } + + private DefaultPluginDescriptor convert(Plugin plugin) { + String pluginId = plugin.getMetadata().getName(); + Plugin.PluginSpec spec = plugin.getSpec(); + DefaultPluginDescriptor defaultPluginDescriptor = + new DefaultPluginDescriptor(pluginId, + spec.getDescription(), + spec.getPluginClass(), + spec.getVersion(), + spec.getRequires(), + spec.getAuthor(), + spec.getLicense()); + // add dependencies + spec.getPluginDependencies().forEach((pluginDepName, versionRequire) -> { + PluginDependency dependency = + new PluginDependency(String.format("%s@%s", pluginDepName, versionRequire)); + defaultPluginDescriptor.addDependency(dependency); + }); + return defaultPluginDescriptor; + } +} diff --git a/src/main/java/run/halo/app/plugin/YamlPluginFinder.java b/src/main/java/run/halo/app/plugin/YamlPluginFinder.java new file mode 100644 index 000000000..507d4b677 --- /dev/null +++ b/src/main/java/run/halo/app/plugin/YamlPluginFinder.java @@ -0,0 +1,97 @@ +package run.halo.app.plugin; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.pf4j.PluginRuntimeException; +import org.pf4j.util.FileUtils; +import org.springframework.core.io.FileSystemResource; +import run.halo.app.extension.Unstructured; +import run.halo.app.infra.utils.YamlUnstructuredLoader; + +/** + *

Reading plugin descriptor data from plugin.yaml.

+ * Example: + *
+ * apiVersion: v1alpha1
+ * kind: Plugin
+ * metadata:
+ *   name: plugin-1
+ *   labels:
+ *     extensions.guqing.xyz/category: attachment
+ * spec:
+ *   # 'version' is a valid semantic version string (see semver.org).
+ *   version: 0.0.1
+ *   requires: ">=2.0.0"
+ *   author: guqing
+ *   logo: example.com/logo.png
+ *   pluginClass: xyz.guqing.plugin.potatoes.PotatoesApp
+ *   pluginDependencies:
+ *    "plugin-2": 1.0.0
+ *   # 'homepage' usually links to the GitHub repository of the plugin
+ *   homepage: example.com
+ *   # 'displayName' explains what the plugin does in only a few words
+ *   displayName: "a name to show"
+ *   description: "Tell me more about this plugin."
+ *   license: MIT
+ * 
+ * + * @author guqing + * @since 2.0.0 + */ +@Slf4j +public class YamlPluginFinder { + public static final String DEFAULT_PROPERTIES_FILE_NAME = "plugin.yaml"; + + private final String propertiesFileName; + + public YamlPluginFinder() { + this(DEFAULT_PROPERTIES_FILE_NAME); + } + + public YamlPluginFinder(String propertiesFileName) { + this.propertiesFileName = propertiesFileName; + } + + public Plugin find(Path pluginPath) { + return readPluginDescriptor(pluginPath); + } + + protected Plugin readPluginDescriptor(Path pluginPath) { + Path propertiesPath = getManifestPath(pluginPath, propertiesFileName); + if (propertiesPath == null) { + throw new PluginRuntimeException("Cannot find the plugin manifest path"); + } + + log.debug("Lookup plugin descriptor in '{}'", propertiesPath); + if (Files.notExists(propertiesPath)) { + throw new PluginRuntimeException("Cannot find '{}' path", propertiesPath); + } + YamlUnstructuredLoader yamlUnstructuredLoader = + new YamlUnstructuredLoader(new FileSystemResource(propertiesPath)); + List unstructuredList = yamlUnstructuredLoader.load(); + if (unstructuredList.size() != 1) { + throw new PluginRuntimeException("Unable to find plugin descriptor file '{}'", + propertiesFileName); + } + Unstructured unstructured = unstructuredList.get(0); + return Unstructured.OBJECT_MAPPER.convertValue(unstructured, + Plugin.class); + } + + protected Path getManifestPath(Path pluginPath, String propertiesFileName) { + if (Files.isDirectory(pluginPath)) { + return pluginPath.resolve(Paths.get(propertiesFileName)); + } else { + // it's a jar file + try { + return FileUtils.getPath(pluginPath, propertiesFileName); + } catch (IOException e) { + throw new PluginRuntimeException(e); + } + } + } +} diff --git a/src/test/java/run/halo/app/plugin/YamlPluginDescriptorFinderTest.java b/src/test/java/run/halo/app/plugin/YamlPluginDescriptorFinderTest.java new file mode 100644 index 000000000..1704a1f72 --- /dev/null +++ b/src/test/java/run/halo/app/plugin/YamlPluginDescriptorFinderTest.java @@ -0,0 +1,87 @@ +package run.halo.app.plugin; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; + +import com.fasterxml.jackson.core.JsonProcessingException; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.json.JSONException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.pf4j.PluginDescriptor; +import org.skyscreamer.jsonassert.JSONAssert; +import org.springframework.util.ResourceUtils; +import run.halo.app.infra.utils.JsonUtils; + +/** + * Tests for {@link YamlPluginDescriptorFinder}. + * + * @author guqing + * @since 2.0.0 + */ +class YamlPluginDescriptorFinderTest { + + private YamlPluginDescriptorFinder yamlPluginDescriptorFinder; + private Path testPath; + + @BeforeEach + void setUp() throws FileNotFoundException { + yamlPluginDescriptorFinder = new YamlPluginDescriptorFinder(); + File file = ResourceUtils.getFile("classpath:plugin/plugin.yaml"); + testPath = file.toPath().getParent(); + } + + @Test + void isApplicable() throws IOException { + // File not exists + boolean applicable = + yamlPluginDescriptorFinder.isApplicable(Path.of("/some/path/test.jar")); + assertThat(applicable).isFalse(); + + // jar file is applicable + Path tempJarFile = Files.createTempFile("test", ".jar"); + applicable = + yamlPluginDescriptorFinder.isApplicable(tempJarFile); + assertThat(applicable).isTrue(); + + // zip file is not applicable + Path tempZipFile = Files.createTempFile("test", ".zip"); + applicable = + yamlPluginDescriptorFinder.isApplicable(tempZipFile); + assertThat(applicable).isFalse(); + + // directory is applicable + applicable = + yamlPluginDescriptorFinder.isApplicable(tempJarFile.getParent()); + assertThat(applicable).isTrue(); + } + + @Test + void find() throws JsonProcessingException, JSONException { + PluginDescriptor pluginDescriptor = yamlPluginDescriptorFinder.find(testPath); + String actual = JsonUtils.objectToJson(pluginDescriptor); + JSONAssert.assertEquals(""" + { + "pluginId": "plugin-1", + "pluginDescription": "Tell me more about this plugin.", + "pluginClass": "run.halo.app.plugin.BasePlugin", + "version": "0.0.1", + "requires": ">=2.0.0", + "provider": "guqing", + "dependencies": [ + { + "pluginId": "banana", + "pluginVersionSupport": "0.0.1", + "optional": false + } + ], + "license": "MIT" + } + """, + actual, + false); + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/plugin/YamlPluginFinderTest.java b/src/test/java/run/halo/app/plugin/YamlPluginFinderTest.java new file mode 100644 index 000000000..e04140265 --- /dev/null +++ b/src/test/java/run/halo/app/plugin/YamlPluginFinderTest.java @@ -0,0 +1,80 @@ +package run.halo.app.plugin; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.core.JsonProcessingException; +import java.io.File; +import java.io.FileNotFoundException; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.json.JSONException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.pf4j.PluginRuntimeException; +import org.skyscreamer.jsonassert.JSONAssert; +import org.springframework.util.ResourceUtils; +import run.halo.app.infra.utils.JsonUtils; + +/** + * Tests for {@link YamlPluginDescriptorFinder}. + * + * @author guqing + * @since 2.0.0 + */ +class YamlPluginFinderTest { + private YamlPluginFinder pluginFinder; + private Path testPath; + + @BeforeEach + void setUp() throws FileNotFoundException { + pluginFinder = new YamlPluginFinder(); + File file = ResourceUtils.getFile("classpath:plugin/plugin.yaml"); + testPath = file.toPath().getParent(); + } + + @Test + void findTest() throws JsonProcessingException, JSONException { + Plugin plugin = pluginFinder.find(testPath); + assertThat(plugin).isNotNull(); + JSONAssert.assertEquals(""" + { + "spec": { + "displayName": "a name to show", + "version": "0.0.1", + "author": "guqing", + "logo": "https://guqing.xyz/avatar", + "pluginDependencies": { + "banana": "0.0.1" + }, + "homepage": "https://github.com/guqing/halo-plugin-1", + "description": "Tell me more about this plugin.", + "license": "MIT", + "requires": ">=2.0.0", + "pluginClass": "run.halo.app.plugin.BasePlugin" + }, + "apiVersion": "v1", + "kind": "Plugin", + "metadata": { + "name": "plugin-1", + "labels": null, + "annotations": null, + "version": null, + "creationTimestamp": null, + "deletionTimestamp": null + } + } + """, + JsonUtils.objectToJson(plugin), + false); + } + + @Test + void findFailedWhenFileNotFound() { + Path test = Paths.get("/tmp"); + assertThatThrownBy(() -> { + pluginFinder.find(test); + }).isInstanceOf(PluginRuntimeException.class) + .hasMessage("Cannot find '/tmp/plugin.yaml' path"); + } +} \ No newline at end of file diff --git a/src/test/resources/plugin/plugin.yaml b/src/test/resources/plugin/plugin.yaml new file mode 100644 index 000000000..b29f43aab --- /dev/null +++ b/src/test/resources/plugin/plugin.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Plugin +metadata: + name: plugin-1 +spec: + # 'version' is a valid semantic version string (see semver.org). + version: 0.0.1 + requires: ">=2.0.0" + author: guqing + logo: https://guqing.xyz/avatar + pluginDependencies: + "banana": "0.0.1" + homepage: https://github.com/guqing/halo-plugin-1 + displayName: "a name to show" + description: "Tell me more about this plugin." + license: MIT \ No newline at end of file