Extend marketplace to accept kar and some improvements (#2490)

Also-by: Wouter Born <github@maindrain.net>
Signed-off-by: Jan N. Klug <jan.n.klug@rub.de>
pull/2493/head
J-N-K 2021-09-23 17:07:34 +02:00 committed by GitHub
parent 0d5b2d6140
commit 4569eea519
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 532 additions and 150 deletions

View File

@ -514,6 +514,12 @@
<version>${project.version}</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core.addon.marketplace.karaf</artifactId>
<version>${project.version}</version>
<scope>compile</scope>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<classpath>
<classpathentry kind="src" output="target/classes" path="src/main/java">
<attributes>
<attribute name="optional" value="true"/>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-11">
<attributes>
<attribute name="maven.pomderived" value="true"/>
<attribute name="annotationpath" value="target/dependency"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.m2e.MAVEN2_CLASSPATH_CONTAINER">
<attributes>
<attribute name="maven.pomderived" value="true"/>
<attribute name="annotationpath" value="target/dependency"/>
</attributes>
</classpathentry>
<classpathentry kind="src" output="target/test-classes" path="src/test/java">
<attributes>
<attribute name="optional" value="true"/>
<attribute name="maven.pomderived" value="true"/>
<attribute name="test" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="output" path="target/classes"/>
</classpath>

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>org.openhab.core.addon.marketplace.karaf</name>
<comment></comment>
<projects>
</projects>
<buildSpec>
<buildCommand>
<name>org.eclipse.jdt.core.javabuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.eclipse.m2e.core.maven2Builder</name>
<arguments>
</arguments>
</buildCommand>
</buildSpec>
<natures>
<nature>org.eclipse.jdt.core.javanature</nature>
<nature>org.eclipse.m2e.core.maven2Nature</nature>
</natures>
</projectDescription>

View File

@ -0,0 +1,14 @@
This content is produced and maintained by the openHAB project.
* Project home: https://www.openhab.org
== Declared Project Licenses
This program and the accompanying materials are made available under the terms
of the Eclipse Public License 2.0 which is available at
https://www.eclipse.org/legal/epl-2.0/.
== Source Code
https://github.com/openhab/openhab-core

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core.reactor.bundles</artifactId>
<version>3.2.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.core.addon.marketplace.karaf</artifactId>
<name>openHAB Core :: Bundles :: Community Marketplace Add-on Service :: Karaf </name>
<dependencies>
<dependency>
<groupId>org.apache.karaf.kar</groupId>
<artifactId>org.apache.karaf.kar.core</artifactId>
<version>${karaf.tooling.version}</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core.addon.marketplace</artifactId>
<version>${project.version}</version>
<scope>compile</scope>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,190 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.addon.marketplace.karaf.internal.community;
import static org.openhab.core.addon.marketplace.internal.community.CommunityMarketplaceAddonService.KAR_CONTENT_TYPE;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.apache.karaf.kar.KarService;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.OpenHAB;
import org.openhab.core.addon.Addon;
import org.openhab.core.addon.marketplace.MarketplaceAddonHandler;
import org.openhab.core.addon.marketplace.MarketplaceHandlerException;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* A {@link CommunityKarafAddonHandler} implementation, which handles add-ons as KAR files and installs them
* using the {@link KarService}.
*
* @author Kai Kreuzer - Initial contribution and API
* @author Yannick Schaus - refactoring
* @author Jan N. Klug - refactor to support kar files
*
*/
@Component(immediate = true)
@NonNullByDefault
public class CommunityKarafAddonHandler implements MarketplaceAddonHandler {
private static final Path KAR_CACHE_PATH = Path.of(OpenHAB.getUserDataFolder(), "marketplace", "kar");
private static final List<String> SUPPORTED_EXT_TYPES = List.of("automation", "binding", "io", "persistence",
"transformation", "ui", "voice");
private static final String KAR_DOWNLOAD_URL_PROPERTY = "kar_download_url";
private static final String KAR_EXTENSION = ".kar";
private final Logger logger = LoggerFactory.getLogger(CommunityKarafAddonHandler.class);
private final KarService karService;
@Activate
public CommunityKarafAddonHandler(@Reference KarService karService) {
this.karService = karService;
ensureCachedKarsAreInstalled();
}
@Override
public boolean supports(String type, String contentType) {
return SUPPORTED_EXT_TYPES.contains(type) && KAR_CONTENT_TYPE.equals(contentType);
}
private Stream<Path> karFilesStream(Path addonDirectory) throws IOException {
return Files.isDirectory(addonDirectory) ? Files.list(addonDirectory).map(Path::getFileName)
.filter(path -> path.toString().endsWith(KAR_EXTENSION)) : Stream.empty();
}
private String pathToKarRepoName(Path path) {
String fileName = path.getFileName().toString();
return fileName.substring(0, fileName.length() - KAR_EXTENSION.length());
}
@Override
@SuppressWarnings("null")
public boolean isInstalled(String addonId) {
try {
Path addonDirectory = getAddonCacheDirectory(addonId);
List<String> repositories = karService.list();
return karFilesStream(addonDirectory).findFirst().map(this::pathToKarRepoName).map(repositories::contains)
.orElse(false);
} catch (Exception e) {
logger.warn("Failed to determine installation status for {}: ", addonId, e);
}
return false;
}
@Override
public void install(Addon addon) throws MarketplaceHandlerException {
try {
URL sourceUrl = new URL((String) addon.getProperties().get(KAR_DOWNLOAD_URL_PROPERTY));
addKarToCache(addon.getId(), sourceUrl);
installFromCache(addon.getId());
} catch (MalformedURLException e) {
throw new MarketplaceHandlerException("Malformed source URL: " + e.getMessage(), e);
}
}
@Override
public void uninstall(Addon addon) throws MarketplaceHandlerException {
try {
Path addonPath = getAddonCacheDirectory(addon.getId());
List<String> repositories = karService.list();
for (Path path : karFilesStream(addonPath).collect(Collectors.toList())) {
String karRepoName = pathToKarRepoName(path);
if (repositories.contains(karRepoName)) {
karService.uninstall(karRepoName);
}
Files.delete(addonPath.resolve(path));
}
Files.delete(addonPath);
} catch (Exception e) {
throw new MarketplaceHandlerException("Failed uninstalling KAR: " + e.getMessage(), e);
}
}
/**
* Downloads a KAR file from a remote source and puts it in the local cache with the add-on ID.
*
* @param addonId the add-on ID
* @param sourceUrl the (online) source where the KAR file can be found
* @throws MarketplaceHandlerException on error
*/
private void addKarToCache(String addonId, URL sourceUrl) throws MarketplaceHandlerException {
try {
String fileName = new File(sourceUrl.toURI().getPath()).getName();
Path addonFile = getAddonCacheDirectory(addonId).resolve(fileName);
Files.createDirectories(addonFile.getParent());
InputStream source = sourceUrl.openStream();
Files.copy(source, addonFile, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException | URISyntaxException e) {
throw new MarketplaceHandlerException("Cannot copy KAR to local cache: " + e.getMessage(), e);
}
}
private void installFromCache(String addonId) throws MarketplaceHandlerException {
Path addonPath = getAddonCacheDirectory(addonId);
if (Files.isDirectory(addonPath)) {
try {
List<Path> karFiles = Files.list(addonPath).collect(Collectors.toList());
if (karFiles.size() != 1) {
throw new MarketplaceHandlerException(
"The local cache folder doesn't contain a single file: " + addonPath, null);
}
try {
karService.install(karFiles.get(0).toUri(), false);
} catch (Exception e) {
throw new MarketplaceHandlerException(
"Cannot install KAR from marketplace cache: " + e.getMessage(), e);
}
} catch (IOException e) {
throw new MarketplaceHandlerException("Could not list files in cache directory " + addonPath, e);
}
}
}
private void ensureCachedKarsAreInstalled() {
try {
if (Files.isDirectory(KAR_CACHE_PATH)) {
Files.list(KAR_CACHE_PATH).filter(Files::isDirectory).map(p -> "marketplace:" + p.getFileName())
.filter(addonId -> !isInstalled(addonId)).forEach(addonId -> {
logger.info("Reinstalling missing marketplace KAR: {}", addonId);
try {
installFromCache(addonId);
} catch (MarketplaceHandlerException e) {
logger.warn("Failed reinstalling add-on from cache", e);
}
});
}
} catch (IOException e) {
logger.warn("Failed to re-install KARs: {}", e.getMessage());
}
}
private Path getAddonCacheDirectory(String addonId) {
return KAR_CACHE_PATH.resolve(addonId.replace("marketplace:", ""));
}
}

View File

@ -35,8 +35,9 @@ public interface MarketplaceAddonHandler {
/**
* Tells whether this handler supports a given add-on.
*
* @param addon the add-on in question
* @return true, if the add-on is supported, false otherwise
* @param type the type of the add-on in question
* @param contentType the content type of the add-on on question
* @return true, if the addon type and contentType are supported, false otherwise
*/
boolean supports(String type, String contentType);

View File

@ -21,6 +21,8 @@ import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.List;
import java.util.stream.Collectors;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.OpenHAB;
@ -44,8 +46,7 @@ import org.slf4j.LoggerFactory;
public abstract class MarketplaceBundleInstaller {
private final Logger logger = LoggerFactory.getLogger(MarketplaceBundleInstaller.class);
private static final String BUNDLE_CACHE_PATH = OpenHAB.getUserDataFolder() + File.separator + "marketplace"
+ File.separator + "bundles";
private static final Path BUNDLE_CACHE_PATH = Path.of(OpenHAB.getUserDataFolder(), "marketplace", "bundles");
/**
* Downloads a bundle file from a remote source and puts it in the local cache with the add-on ID.
@ -57,14 +58,13 @@ public abstract class MarketplaceBundleInstaller {
protected void addBundleToCache(String addonId, URL sourceUrl) throws MarketplaceHandlerException {
try {
String fileName = new File(sourceUrl.toURI().getPath()).getName();
File addonFile = new File(getAddonCacheDirectory(addonId), fileName);
addonFile.getParentFile().mkdirs();
Path addonFile = getAddonCacheDirectory(addonId).resolve(fileName);
Files.createDirectories(addonFile.getParent());
InputStream source = sourceUrl.openStream();
Path outputPath = Path.of(addonFile.toURI());
Files.copy(source, outputPath, StandardCopyOption.REPLACE_EXISTING);
Files.copy(source, addonFile, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException | URISyntaxException e) {
throw new MarketplaceHandlerException("Cannot copy bundle to local cache: " + e.getMessage());
throw new MarketplaceHandlerException("Cannot copy bundle to local cache: " + e.getMessage(), e);
}
}
@ -76,26 +76,27 @@ public abstract class MarketplaceBundleInstaller {
* @throws MarketplaceHandlerException
*/
protected void installFromCache(BundleContext bundleContext, String addonId) throws MarketplaceHandlerException {
File addonPath = getAddonCacheDirectory(addonId);
if (addonPath.exists() && addonPath.isDirectory()) {
File[] bundleFiles = addonPath.listFiles();
if (bundleFiles.length != 1) {
throw new MarketplaceHandlerException(
"The local cache folder doesn't contain a single file: " + addonPath.toString());
}
try (FileInputStream fileInputStream = new FileInputStream(bundleFiles[0])) {
Bundle bundle = bundleContext.installBundle(addonId, fileInputStream);
try {
bundle.start();
} catch (BundleException e) {
logger.warn("The marketplace bundle was successfully installed but doesn't start: {}",
e.getMessage());
Path addonPath = getAddonCacheDirectory(addonId);
if (Files.isDirectory(addonPath)) {
try {
List<Path> bundleFiles = Files.list(addonPath).collect(Collectors.toList());
if (bundleFiles.size() != 1) {
throw new MarketplaceHandlerException(
"The local cache folder doesn't contain a single file: " + addonPath, null);
}
try (FileInputStream fileInputStream = new FileInputStream(bundleFiles.get(0).toFile())) {
Bundle bundle = bundleContext.installBundle(addonId, fileInputStream);
try {
bundle.start();
} catch (BundleException e) {
logger.warn("The marketplace bundle was successfully installed but doesn't start: {}",
e.getMessage());
}
}
} catch (IOException | BundleException e) {
throw new MarketplaceHandlerException(
"Cannot install bundle from marketplace cache: " + e.getMessage());
throw new MarketplaceHandlerException("Cannot install bundle from marketplace cache: " + e.getMessage(),
e);
}
}
}
@ -117,21 +118,24 @@ public abstract class MarketplaceBundleInstaller {
* @param addonId the add-on ID
*/
protected void uninstallBundle(BundleContext bundleContext, String addonId) throws MarketplaceHandlerException {
File addonPath = getAddonCacheDirectory(addonId);
if (addonPath.exists() && addonPath.isDirectory()) {
for (File bundleFile : addonPath.listFiles()) {
bundleFile.delete();
try {
Path addonPath = getAddonCacheDirectory(addonId);
if (Files.isDirectory(addonPath)) {
for (Path bundleFile : Files.list(addonPath).collect(Collectors.toList())) {
Files.delete(bundleFile);
}
}
Files.delete(addonPath);
} catch (IOException e) {
throw new MarketplaceHandlerException("Failed to delete bundle-files: " + e.getMessage(), e);
}
addonPath.delete();
if (isBundleInstalled(bundleContext, addonId)) {
Bundle bundle = bundleContext.getBundle(addonId);
try {
bundle.stop();
bundle.uninstall();
} catch (BundleException e) {
throw new MarketplaceHandlerException("Failed uninstalling bundle: " + e.getMessage());
throw new MarketplaceHandlerException("Failed uninstalling bundle: " + e.getMessage(), e);
}
}
}
@ -142,26 +146,24 @@ public abstract class MarketplaceBundleInstaller {
* @param bundleContext the {@link BundleContext} to use to look up the bundles
*/
protected void ensureCachedBundlesAreInstalled(BundleContext bundleContext) {
File addonPath = new File(BUNDLE_CACHE_PATH);
if (addonPath.exists() && addonPath.isDirectory()) {
for (File bundleFile : addonPath.listFiles()) {
if (bundleFile.isDirectory()) {
String addonId = "marketplace:" + bundleFile.getName();
if (!isBundleInstalled(bundleContext, addonId)) {
logger.info("Reinstalling missing marketplace bundle: {}", addonId);
try {
installFromCache(bundleContext, addonId);
} catch (MarketplaceHandlerException e) {
logger.warn("Failed reinstalling add-on from cache", e);
}
}
}
bundleFile.delete();
try {
if (Files.isDirectory(BUNDLE_CACHE_PATH)) {
Files.list(BUNDLE_CACHE_PATH).filter(Files::isDirectory).map(p -> "marketplace:" + p.getFileName())
.filter(addonId -> !isBundleInstalled(bundleContext, addonId)).forEach(addonId -> {
logger.info("Reinstalling missing marketplace bundle: {}", addonId);
try {
installFromCache(bundleContext, addonId);
} catch (MarketplaceHandlerException e) {
logger.warn("Failed reinstalling add-on from cache", e);
}
});
}
} catch (IOException e) {
logger.warn("Failed to re-install bundles: {}", e.getMessage());
}
}
private File getAddonCacheDirectory(String addonId) {
return new File(BUNDLE_CACHE_PATH + File.separator + addonId.replace("marketplace:", ""));
private Path getAddonCacheDirectory(String addonId) {
return BUNDLE_CACHE_PATH.resolve(addonId.replace("marketplace:", ""));
}
}

View File

@ -13,6 +13,7 @@
package org.openhab.core.addon.marketplace;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* This is an exception that can be thrown by {@link MarketplaceAddonHandler}s if some operation fails.
@ -30,7 +31,7 @@ public class MarketplaceHandlerException extends Exception {
*
* @param message A message describing the issue
*/
public MarketplaceHandlerException(String message) {
super(message);
public MarketplaceHandlerException(String message, @Nullable Throwable cause) {
super(message, cause);
}
}

View File

@ -12,12 +12,13 @@
*/
package org.openhab.core.addon.marketplace.internal.community;
import static org.openhab.core.addon.marketplace.internal.community.CommunityMarketplaceAddonService.JAR_CONTENT_TYPE;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.addon.Addon;
import org.openhab.core.addon.marketplace.MarketplaceAddonHandler;
import org.openhab.core.addon.marketplace.MarketplaceBundleInstaller;
@ -25,9 +26,6 @@ import org.openhab.core.addon.marketplace.MarketplaceHandlerException;
import org.osgi.framework.BundleContext;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* A {@link MarketplaceAddonHandler} implementation, which handles add-ons as jar files (specifically, OSGi
@ -40,34 +38,24 @@ import org.slf4j.LoggerFactory;
*
*/
@Component(immediate = true)
@NonNullByDefault
public class CommunityBundleAddonHandler extends MarketplaceBundleInstaller implements MarketplaceAddonHandler {
// add-on types supported by this handler
private static final List<String> SUPPORTED_EXT_TYPES = Arrays.asList("binding");
private static final String BUNDLE_CONTENTTYPE = "application/vnd.openhab.bundle";
private static final List<String> SUPPORTED_EXT_TYPES = List.of("automation", "binding", "io", "persistence",
"transformation", "ui", "voice");
private static final String JAR_DOWNLOAD_URL_PROPERTY = "jar_download_url";
private final Logger logger = LoggerFactory.getLogger(CommunityBundleAddonHandler.class);
private BundleContext bundleContext;
private final BundleContext bundleContext;
@Activate
protected void activate(BundleContext bundleContext, Map<String, Object> config) {
public CommunityBundleAddonHandler(BundleContext bundleContext) {
this.bundleContext = bundleContext;
ensureCachedBundlesAreInstalled(bundleContext);
}
@Deactivate
protected void deactivate() {
this.bundleContext = null;
}
@Override
public boolean supports(String type, String contentType) {
// we support only certain extension types, and only as pure OSGi bundles
return SUPPORTED_EXT_TYPES.contains(type) && contentType.equals(BUNDLE_CONTENTTYPE);
return SUPPORTED_EXT_TYPES.contains(type) && contentType.equals(JAR_CONTENT_TYPE);
}
@Override
@ -77,13 +65,12 @@ public class CommunityBundleAddonHandler extends MarketplaceBundleInstaller impl
@Override
public void install(Addon addon) throws MarketplaceHandlerException {
URL sourceUrl;
try {
sourceUrl = new URL((String) addon.getProperties().get(JAR_DOWNLOAD_URL_PROPERTY));
URL sourceUrl = new URL((String) addon.getProperties().get(JAR_DOWNLOAD_URL_PROPERTY));
addBundleToCache(addon.getId(), sourceUrl);
installFromCache(bundleContext, addon.getId());
} catch (MalformedURLException e) {
throw new MarketplaceHandlerException("Malformed source URL: " + e.getMessage());
throw new MarketplaceHandlerException("Malformed source URL: " + e.getMessage(), e);
}
}

View File

@ -12,6 +12,8 @@
*/
package org.openhab.core.addon.marketplace.internal.community;
import static org.openhab.core.addon.Addon.CODE_MATURITY_LEVELS;
import java.io.InputStreamReader;
import java.io.Reader;
import java.net.URI;
@ -25,6 +27,7 @@ import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@ -36,12 +39,12 @@ import org.openhab.core.addon.AddonService;
import org.openhab.core.addon.AddonType;
import org.openhab.core.addon.marketplace.MarketplaceAddonHandler;
import org.openhab.core.addon.marketplace.MarketplaceHandlerException;
import org.openhab.core.addon.marketplace.internal.community.model.DiscourseCategoryResponse;
import org.openhab.core.addon.marketplace.internal.community.model.DiscourseCategoryResponse.DiscoursePosterInfo;
import org.openhab.core.addon.marketplace.internal.community.model.DiscourseCategoryResponse.DiscourseTopicItem;
import org.openhab.core.addon.marketplace.internal.community.model.DiscourseCategoryResponse.DiscourseUser;
import org.openhab.core.addon.marketplace.internal.community.model.DiscourseTopicResponse;
import org.openhab.core.addon.marketplace.internal.community.model.DiscourseTopicResponse.DiscoursePostLink;
import org.openhab.core.addon.marketplace.internal.community.model.DiscourseCategoryResponseDTO;
import org.openhab.core.addon.marketplace.internal.community.model.DiscourseCategoryResponseDTO.DiscoursePosterInfo;
import org.openhab.core.addon.marketplace.internal.community.model.DiscourseCategoryResponseDTO.DiscourseTopicItem;
import org.openhab.core.addon.marketplace.internal.community.model.DiscourseCategoryResponseDTO.DiscourseUser;
import org.openhab.core.addon.marketplace.internal.community.model.DiscourseTopicResponseDTO;
import org.openhab.core.addon.marketplace.internal.community.model.DiscourseTopicResponseDTO.DiscoursePostLink;
import org.openhab.core.config.core.ConfigurableService;
import org.openhab.core.events.Event;
import org.openhab.core.events.EventPublisher;
@ -68,14 +71,16 @@ import com.google.gson.GsonBuilder;
property = Constants.SERVICE_PID + "=org.openhab.marketplace")
@ConfigurableService(category = "system", label = "Community Marketplace", description_uri = CommunityMarketplaceAddonService.CONFIG_URI)
public class CommunityMarketplaceAddonService implements AddonService {
public static final String JAR_CONTENT_TYPE = "application/vnd.openhab.bundle";
public static final String KAR_CONTENT_TYPE = "application/vnd.openhab.feature;type=karfile";
public static final String RULETEMPLATES_CONTENT_TYPE = "application/vnd.openhab.ruletemplate";
public static final String UIWIDGETS_CONTENT_TYPE = "application/vnd.openhab.uicomponent;type=widget";
// constants for the configuration properties
static final String CONFIG_URI = "system:marketplace";
static final String CONFIG_API_KEY = "apiKey";
static final String CONFIG_SHOW_UNPUBLISHED_ENTRIES_KEY = "showUnpublished";
private final Logger logger = LoggerFactory.getLogger(CommunityMarketplaceAddonService.class);
private static final String COMMUNITY_BASE_URL = "https://community.openhab.org";
private static final String COMMUNITY_MARKETPLACE_URL = COMMUNITY_BASE_URL + "/c/marketplace/69/l/latest";
private static final String COMMUNITY_TOPIC_URL = COMMUNITY_BASE_URL + "/t/";
@ -86,33 +91,31 @@ public class CommunityMarketplaceAddonService implements AddonService {
private static final String YAML_CODE_MARKUP_START = "<pre><code class=\"lang-yaml\">";
private static final String CODE_MARKUP_END = "</code></pre>";
private HashMap<Integer, AddonType> types = new HashMap<Integer, AddonType>(3);
private static final Integer BINDINGS_CATEGORY = 73;
private static final Integer BUNDLES_CATEGORY = 73;
private static final Integer RULETEMPLATES_CATEGORY = 74;
private static final Integer UIWIDGETS_CATEGORY = 75;
private static final String PUBLISHED_TAG = "published";
private HashMap<String, String> contentTypes = new HashMap<String, String>(3);
private static final String BINDINGS_CONTENT_TYPE = "application/vnd.openhab.bundle";
private static final String RULETEMPLATES_CONTENT_TYPE = "application/vnd.openhab.ruletemplate";
private static final String UIWIDGETS_CONTENT_TYPE = "application/vnd.openhab.uicomponent;type=widget";
private static final Map<String, AddonType> TAG_ADDON_TYPE_MAP = Map.of( //
"automation", new AddonType("automation", "Automation"), //
"binding", new AddonType("binding", "Bindings"), //
"io", new AddonType("io", "I/O Services"), //
"persistence", new AddonType("persistence", "Persistence Services"), //
"transformation", new AddonType("transformation", "Transformations"), //
"ui", new AddonType("ui", "User Interfaces"), //
"voice", new AddonType("voice", "Voices"));
private final Logger logger = LoggerFactory.getLogger(CommunityMarketplaceAddonService.class);
private final Gson gson = new GsonBuilder().setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'").create();
private final Set<MarketplaceAddonHandler> addonHandlers = new HashSet<>();
private EventPublisher eventPublisher;
private String apiKey = null;
private boolean showUnpublished = false;
@Activate
protected void activate(Map<String, Object> config) {
types.put(BINDINGS_CATEGORY, new AddonType("binding", "Bindings"));
types.put(RULETEMPLATES_CATEGORY, new AddonType("automation", "Automation"));
types.put(UIWIDGETS_CATEGORY, new AddonType("ui", "User Interfaces"));
contentTypes.put("binding", BINDINGS_CONTENT_TYPE);
contentTypes.put("automation", RULETEMPLATES_CONTENT_TYPE);
contentTypes.put("ui", UIWIDGETS_CONTENT_TYPE);
modified(config);
}
@ -161,7 +164,7 @@ public class CommunityMarketplaceAddonService implements AddonService {
@Override
public List<Addon> getAddons(Locale locale) {
try {
List<DiscourseCategoryResponse> pages = new ArrayList<DiscourseCategoryResponse>();
List<DiscourseCategoryResponseDTO> pages = new ArrayList<>();
URL url = new URL(COMMUNITY_MARKETPLACE_URL);
int pageNb = 1;
@ -173,7 +176,7 @@ public class CommunityMarketplaceAddonService implements AddonService {
}
try (Reader reader = new InputStreamReader(connection.getInputStream())) {
DiscourseCategoryResponse parsed = gson.fromJson(reader, DiscourseCategoryResponse.class);
DiscourseCategoryResponseDTO parsed = gson.fromJson(reader, DiscourseCategoryResponseDTO.class);
pages.add(parsed);
if (parsed.topic_list.more_topics_url != null) {
@ -191,7 +194,7 @@ public class CommunityMarketplaceAddonService implements AddonService {
.map(t -> convertTopicItemToAddon(t, users)).collect(Collectors.toList());
} catch (Exception e) {
logger.error("Unable to retrieve marketplace add-ons", e);
return new ArrayList<Addon>();
return List.of();
}
}
@ -207,7 +210,7 @@ public class CommunityMarketplaceAddonService implements AddonService {
}
try (Reader reader = new InputStreamReader(connection.getInputStream())) {
DiscourseTopicResponse parsed = gson.fromJson(reader, DiscourseTopicResponse.class);
DiscourseTopicResponseDTO parsed = gson.fromJson(reader, DiscourseTopicResponseDTO.class);
return convertTopicToAddon(parsed);
}
} catch (Exception e) {
@ -217,7 +220,7 @@ public class CommunityMarketplaceAddonService implements AddonService {
@Override
public List<AddonType> getTypes(Locale locale) {
return new ArrayList<AddonType>(types.values());
return new ArrayList<>(TAG_ADDON_TYPE_MAP.values());
}
@Override
@ -270,6 +273,45 @@ public class CommunityMarketplaceAddonService implements AddonService {
return "";
}
private @Nullable AddonType getAddonType(@Nullable Integer category, String[] tags) {
// check if we can determine the addon type from the category
if (RULETEMPLATES_CATEGORY.equals(category)) {
return TAG_ADDON_TYPE_MAP.get("automation");
} else if (UIWIDGETS_CATEGORY.equals(category)) {
return TAG_ADDON_TYPE_MAP.get("ui");
} else if (BUNDLES_CATEGORY.equals(category)) {
// try to get it from tags if we have tags
for (String tag : tags) {
AddonType addonType = TAG_ADDON_TYPE_MAP.get(tag);
if (addonType != null) {
return addonType;
}
}
}
// or return null
return null;
}
private String getContentType(@Nullable Integer category, String[] tags) {
// check if we can determine the addon type from the category
if (RULETEMPLATES_CATEGORY.equals(category)) {
return RULETEMPLATES_CONTENT_TYPE;
} else if (UIWIDGETS_CATEGORY.equals(category)) {
return UIWIDGETS_CONTENT_TYPE;
} else if (BUNDLES_CATEGORY.equals(category)) {
if (Arrays.asList(tags).contains("kar")) {
return KAR_CONTENT_TYPE;
} else {
// default to plain jar bundle for addons
return JAR_CONTENT_TYPE;
}
}
// empty string if content type could not be defined
return "";
}
/**
* Transforms a {@link DiscourseTopicItem} to a {@link Addon}
*
@ -278,16 +320,18 @@ public class CommunityMarketplaceAddonService implements AddonService {
*/
private Addon convertTopicItemToAddon(DiscourseTopicItem topic, List<DiscourseUser> users) {
String id = ADDON_ID_PREFIX + topic.id.toString();
AddonType addonType = types.get(topic.category_id);
String[] tags = Objects.requireNonNullElse(topic.tags, new String[0]);
AddonType addonType = getAddonType(topic.category_id, tags);
String type = (addonType != null) ? addonType.getId() : "";
String contentType = (contentTypes.get(type) != null) ? contentTypes.get(type) : "";
String contentType = getContentType(topic.category_id, tags);
String version = "";
String title = topic.title;
String link = COMMUNITY_TOPIC_URL + topic.id.toString();
int likeCount = topic.like_count;
int views = topic.views;
int postsCount = topic.posts_count;
String[] tags = topic.tags;
Date createdDate = topic.created_at;
String author = "";
boolean verifiedAuthor = false;
@ -297,6 +341,14 @@ public class CommunityMarketplaceAddonService implements AddonService {
}
}
String maturity = null;
for (String tag : tags) {
if (CODE_MATURITY_LEVELS.contains(tag)) {
maturity = tag;
break;
}
}
HashMap<String, Object> properties = new HashMap<>(10);
properties.put("created_at", createdDate);
properties.put("like_count", likeCount);
@ -323,9 +375,9 @@ public class CommunityMarketplaceAddonService implements AddonService {
String connection = "";
String backgroundColor = "";
String imageLink = topic.image_url;
Addon addon = new Addon(id, type, title, version, contentType, link, author, verifiedAuthor, installed,
description, detailedDescription, configDescriptionURI, keywords, countries, connection,
backgroundColor, imageLink, properties);
Addon addon = new Addon(id, type, title, version, maturity, contentType, link, author, verifiedAuthor,
installed, description, detailedDescription, configDescriptionURI, keywords, countries, null,
connection, backgroundColor, imageLink, properties);
return addon;
}
@ -341,29 +393,39 @@ public class CommunityMarketplaceAddonService implements AddonService {
}
/**
* Transforms a {@link DiscourseTopicResponse} to a {@link Addon}
* Transforms a {@link DiscourseTopicResponseDTO} to a {@link Addon}
*
* @param topic the topic
* @return the list item
*/
private Addon convertTopicToAddon(DiscourseTopicResponse topic) {
private Addon convertTopicToAddon(DiscourseTopicResponseDTO topic) {
String id = ADDON_ID_PREFIX + topic.id.toString();
AddonType addonType = types.get(topic.category_id);
String[] tags = Objects.requireNonNullElse(topic.tags, new String[0]);
AddonType addonType = getAddonType(topic.category_id, tags);
String type = (addonType != null) ? addonType.getId() : "";
String contentType = contentTypes.get(type);
String contentType = getContentType(topic.category_id, tags);
String version = "";
String title = topic.title;
String link = COMMUNITY_TOPIC_URL + topic.id.toString();
int likeCount = topic.like_count;
int views = topic.views;
int postsCount = topic.posts_count;
String[] tags = topic.tags;
Date createdDate = topic.post_stream.posts[0].created_at;
Date updatedDate = topic.post_stream.posts[0].updated_at;
Date lastPostedDate = topic.last_posted;
String author = topic.post_stream.posts[0].display_username;
boolean verifiedAuthor = false;
String maturity = null;
for (String tag : tags) {
if (CODE_MATURITY_LEVELS.contains(tag)) {
maturity = tag;
break;
}
}
HashMap<String, Object> properties = new HashMap<>(10);
properties.put("created_at", createdDate);
properties.put("updated_at", updatedDate);
@ -382,6 +444,9 @@ public class CommunityMarketplaceAddonService implements AddonService {
if (postLink.url.endsWith(".jar")) {
properties.put("jar_download_url", postLink.url);
}
if (postLink.url.endsWith(".kar")) {
properties.put("kar_download_url", postLink.url);
}
if (postLink.url.endsWith(".json")) {
properties.put("json_download_url", postLink.url);
}
@ -406,7 +471,7 @@ public class CommunityMarketplaceAddonService implements AddonService {
// try to use an handler to determine if the add-on is installed
boolean installed = false;
for (MarketplaceAddonHandler handler : addonHandlers) {
if (handler.supports(type, (contentType != null) ? contentType : "")) {
if (handler.supports(type, contentType)) {
if (handler.isInstalled(id)) {
installed = true;
}
@ -418,9 +483,9 @@ public class CommunityMarketplaceAddonService implements AddonService {
String countries = "";
String connection = "";
String backgroundColor = "";
Addon addon = new Addon(id, type, title, version, contentType, link, author, verifiedAuthor, installed,
description, detailedDescription, configDescriptionURI, keywords, countries, connection,
backgroundColor, null, properties);
Addon addon = new Addon(id, type, title, version, maturity, contentType, link, author, verifiedAuthor,
installed, description, detailedDescription, configDescriptionURI, keywords, countries, null,
connection, backgroundColor, null, properties);
return addon;
}

View File

@ -17,13 +17,14 @@ import java.io.InputStream;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import org.eclipse.jdt.annotation.NonNull;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.addon.Addon;
import org.openhab.core.addon.marketplace.MarketplaceAddonHandler;
import org.openhab.core.addon.marketplace.MarketplaceHandlerException;
import org.openhab.core.addon.marketplace.internal.automation.MarketplaceRuleTemplateProvider;
import org.openhab.core.automation.template.RuleTemplateProvider;
import org.openhab.core.storage.Storage;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
@ -38,7 +39,8 @@ import org.slf4j.LoggerFactory;
* @author Yannick Schaus - refactoring
*
*/
@Component
@Component(immediate = true)
@NonNullByDefault
public class CommunityRuleTemplateAddonHandler implements MarketplaceAddonHandler {
private static final String JSON_DOWNLOAD_URL_PROPERTY = "json_download_url";
private static final String YAML_DOWNLOAD_URL_PROPERTY = "yaml_download_url";
@ -48,18 +50,14 @@ public class CommunityRuleTemplateAddonHandler implements MarketplaceAddonHandle
private final Logger logger = LoggerFactory.getLogger(CommunityRuleTemplateAddonHandler.class);
private MarketplaceRuleTemplateProvider marketplaceRuleTemplateProvider;
private final MarketplaceRuleTemplateProvider marketplaceRuleTemplateProvider;
@Reference
protected void setMarketplaceRuleTemplateProvider(MarketplaceRuleTemplateProvider marketplaceRuleTemplateProvider) {
@Activate
public CommunityRuleTemplateAddonHandler(
@Reference MarketplaceRuleTemplateProvider marketplaceRuleTemplateProvider) {
this.marketplaceRuleTemplateProvider = marketplaceRuleTemplateProvider;
}
protected void unsetMarketplaceRuleTemplateProvider(
MarketplaceRuleTemplateProvider marketplaceRuleTemplateProvider) {
this.marketplaceRuleTemplateProvider = null;
}
@Override
public boolean supports(String type, String contentType) {
return "automation".equals(type) && RULETEMPLATES_CONTENT_TYPE.equals(contentType);
@ -81,18 +79,18 @@ public class CommunityRuleTemplateAddonHandler implements MarketplaceAddonHandle
template = getTemplateFromURL((String) addon.getProperties().get(YAML_DOWNLOAD_URL_PROPERTY));
marketplaceRuleTemplateProvider.addTemplateAsYAML(addon.getId(), template);
} else if (addon.getProperties().containsKey(JSON_CONTENT_PROPERTY)) {
template = (@NonNull String) addon.getProperties().get(JSON_CONTENT_PROPERTY);
template = (String) addon.getProperties().get(JSON_CONTENT_PROPERTY);
marketplaceRuleTemplateProvider.addTemplateAsJSON(addon.getId(), template);
} else if (addon.getProperties().containsKey(YAML_CONTENT_PROPERTY)) {
template = (@NonNull String) addon.getProperties().get(YAML_CONTENT_PROPERTY);
template = (String) addon.getProperties().get(YAML_CONTENT_PROPERTY);
marketplaceRuleTemplateProvider.addTemplateAsYAML(addon.getId(), template);
}
} catch (IOException e) {
logger.error("Rule template from marketplace cannot be downloaded: {}", e.getMessage());
throw new MarketplaceHandlerException("Template cannot be downloaded.");
throw new MarketplaceHandlerException("Template cannot be downloaded.", e);
} catch (Exception e) {
logger.error("Rule template from marketplace is invalid: {}", e.getMessage());
throw new MarketplaceHandlerException("Template is not valid.");
throw new MarketplaceHandlerException("Template is not valid.", e);
}
}

View File

@ -18,7 +18,7 @@ import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import org.eclipse.jdt.annotation.NonNull;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.addon.Addon;
import org.openhab.core.addon.marketplace.MarketplaceAddonHandler;
import org.openhab.core.addon.marketplace.MarketplaceHandlerException;
@ -41,7 +41,8 @@ import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
* @author Yannick Schaus - Initial contribution and API
*
*/
@Component
@Component(immediate = true)
@NonNullByDefault
public class CommunityUIWidgetAddonHandler implements MarketplaceAddonHandler {
private static final String YAML_DOWNLOAD_URL_PROPERTY = "yaml_download_url";
private static final String YAML_CONTENT_PROPERTY = "yaml_content";
@ -77,17 +78,17 @@ public class CommunityUIWidgetAddonHandler implements MarketplaceAddonHandler {
if (addon.getProperties().containsKey(YAML_DOWNLOAD_URL_PROPERTY)) {
widget = getWidgetFromURL((String) addon.getProperties().get(YAML_DOWNLOAD_URL_PROPERTY));
} else if (addon.getProperties().containsKey(YAML_CONTENT_PROPERTY)) {
widget = (@NonNull String) addon.getProperties().get(YAML_CONTENT_PROPERTY);
widget = (String) addon.getProperties().get(YAML_CONTENT_PROPERTY);
} else {
throw new IllegalArgumentException("Couldn't find the widget in the add-on entry");
}
addWidgetAsYAML(addon.getId(), widget);
} catch (IOException e) {
logger.error("Widget from marketplace cannot be downloaded: {}", e.getMessage());
throw new MarketplaceHandlerException("Widget cannot be downloaded.");
throw new MarketplaceHandlerException("Widget cannot be downloaded.", e);
} catch (Exception e) {
logger.error("Widget from marketplace is invalid: {}", e.getMessage());
throw new MarketplaceHandlerException("Widget is not valid.");
throw new MarketplaceHandlerException("Widget is not valid.", e);
}
}

View File

@ -20,7 +20,7 @@ import java.util.Date;
* @author Yannick Schaus - Initial contribution
*
*/
public class DiscourseCategoryResponse {
public class DiscourseCategoryResponseDTO {
public DiscourseUser[] users;
public DiscourseTopicList topic_list;

View File

@ -20,7 +20,7 @@ import java.util.Date;
* @author Yannick Schaus - Initial contribution
*
*/
public class DiscourseTopicResponse {
public class DiscourseTopicResponseDTO {
public Integer id;
public DiscoursePostStream post_stream;

View File

@ -80,8 +80,9 @@ public class SampleAddonService implements AddonService {
String description = createDescription();
String imageLink = null;
String backgroundColor = createRandomColor();
Addon extension = new Addon(id, typeId, label, version, "example/vnd.openhab.addon", link, "John Doe",
false, installed, description, backgroundColor, imageLink, null, null, null, null, null, null);
Addon extension = new Addon(id, typeId, label, version, "stable", "example/vnd.openhab.addon", link,
"John Doe", false, installed, description, backgroundColor, imageLink, null, null, null, null,
null, null, null);
extensions.put(extension.getId(), extension);
}
}

View File

@ -13,6 +13,7 @@
package org.openhab.core.addon;
import java.util.Map;
import java.util.Set;
/**
* This class defines an add-on.
@ -21,10 +22,12 @@ import java.util.Map;
* @author Yannick Schaus - Add fields
*/
public class Addon {
public static final Set<String> CODE_MATURITY_LEVELS = Set.of("alpha", "beta", "mature", "stable");
private final String id;
private final String label;
private final String version;
private final String maturity;
private final String contentType;
private final String link;
private final String author;
@ -36,6 +39,7 @@ public class Addon {
private final String configDescriptionURI;
private final String keywords;
private final String countries;
private final String license;
private final String connection;
private final String backgroundColor;
private final String imageLink;
@ -56,8 +60,8 @@ public class Addon {
*/
public Addon(String id, String type, String label, String version, String contentType, String link, String author,
boolean verifiedAuthor, boolean installed) {
this(id, type, label, version, contentType, link, author, verifiedAuthor, installed, null, null, null, null,
null, null, null, null, null);
this(id, type, label, version, null, contentType, link, author, verifiedAuthor, installed, null, null, null,
null, null, null, null, null, null, null);
}
/**
@ -67,28 +71,39 @@ public class Addon {
* @param type the type id of the add-on
* @param label the label of the add-on
* @param version the version of the add-on
* @param maturity the maturity level of this version
* @param contentType the content type of the add-on
* @param description the detailed description of the add-on (may be null)
* @param backgroundColor for displaying the add-on (may be null)
* @param link the link to find more information about the add-on (may be null)
* @param link the link to find more information about the add-on (may be null)*
* @param author the author of the add-on
* @param verifiedAuthor true, if the author is verified
* @param imageLink the link to an image (png/svg) (may be null)
* @param installed true, if the add-on is installed, false otherwise
* @param description the description of the add-on (may be null)
* @param detailedDescription the detailed description of the add-on (may be null)
* @param configDescriptionURI the URI to the configuration description for this add-on
* @param keywords the keywords for this add-on
* @param countries a comma-separated list of ISO 3166 codes relevant to this add-on
* @param license the SPDX license identifier
* @param connection a string describing the type of connection (local or cloud, push or pull...) this add-on uses,
* if applicable.
* @param backgroundColor for displaying the add-on (may be null)
* @param imageLink the link to an image (png/svg) (may be null)
* @param properties a {@link Map} containing addition information
*/
public Addon(String id, String type, String label, String version, String contentType, String link, String author,
boolean verifiedAuthor, boolean installed, String description, String detailedDescription,
String configDescriptionURI, String keywords, String countries, String connection, String backgroundColor,
String imageLink, Map<String, Object> properties) {
public Addon(String id, String type, String label, String version, String maturity, String contentType, String link,
String author, boolean verifiedAuthor, boolean installed, String description, String detailedDescription,
String configDescriptionURI, String keywords, String countries, String license, String connection,
String backgroundColor, String imageLink, Map<String, Object> properties) {
this.id = id;
this.label = label;
this.version = version;
this.maturity = maturity;
this.contentType = contentType;
this.description = description;
this.detailedDescription = detailedDescription;
this.configDescriptionURI = configDescriptionURI;
this.keywords = keywords;
this.countries = countries;
this.license = license;
this.connection = connection;
this.backgroundColor = backgroundColor;
this.link = link;
@ -149,6 +164,13 @@ public class Addon {
return version;
}
/**
* The maturity level of this version
*/
public String getMaturity() {
return maturity;
}
/**
* The content type of the add-on
*/
@ -191,6 +213,13 @@ public class Addon {
return countries;
}
/**
* The SPDX License identifier for this addon
*/
public String getLicense() {
return license;
}
/**
* A string describing the type of connection (local or cloud, push or pull...) this add-on uses, if applicable.
*/

View File

@ -18,6 +18,7 @@
<modules>
<module>org.openhab.core.addon.marketplace</module>
<module>org.openhab.core.addon.marketplace.karaf</module>
<module>org.openhab.core.auth.jaas</module>
<module>org.openhab.core.auth.oauth2client</module>
<module>org.openhab.core.automation</module>

View File

@ -65,8 +65,10 @@
</feature>
<feature name="openhab-core-addon-marketplace" version="${project.version}">
<feature>kar</feature>
<feature>openhab-core-base</feature>
<bundle>mvn:org.openhab.core.bundles/org.openhab.core.addon.marketplace/${project.version}</bundle>
<bundle>mvn:org.openhab.core.bundles/org.openhab.core.addon.marketplace.karaf/${project.version}</bundle>
<requirement>openhab.tp;filter:="(feature=jackson)"</requirement>
<feature dependency="true">openhab.tp-jackson</feature>
<feature dependency="true">openhab-core-ui</feature>