mirror of
https://github.com/danieldemus/openhab-addons
synced 2026-07-29 12:34:21 +02:00
[openhabcloud] Provide core WebhookService (#20748)
* [openhabcloud] Provide core WebhookService Signed-off-by: Dan Cunningham <dan@digitaldan.com>
This commit is contained in:
@@ -15,6 +15,12 @@
|
||||
<name>openHAB Add-ons :: Bundles :: IO :: openHAB Cloud Connector</name>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.openhab.core.bundles</groupId>
|
||||
<artifactId>org.openhab.core.io.rest</artifactId>
|
||||
<version>${project.version}</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.json</groupId>
|
||||
<artifactId>json</artifactId>
|
||||
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 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.io.openhabcloud;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* Service interface for requesting webhook URLs from the openHAB Cloud.
|
||||
*
|
||||
* Other bindings can consume this service via OSGi {@code @Reference} to obtain
|
||||
* publicly-reachable webhook URLs. When an external service calls the webhook URL,
|
||||
* the cloud proxies the request to the specified local path on this openHAB instance.
|
||||
*
|
||||
* <p>
|
||||
* Usage example:
|
||||
*
|
||||
* <pre>
|
||||
* {@code @Reference(cardinality = ReferenceCardinality.OPTIONAL, policy = ReferencePolicy.DYNAMIC)}
|
||||
* protected void setWebhookService(WebhookService service) {
|
||||
* this.webhookService = service;
|
||||
* }
|
||||
*
|
||||
* // In initialize():
|
||||
* webhookService.requestWebhook("/myBinding/callback")
|
||||
* .thenAccept(url -> externalApi.registerCallback(url));
|
||||
* </pre>
|
||||
*
|
||||
* @author Dan Cunningham - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public interface WebhookService {
|
||||
|
||||
/**
|
||||
* Request a webhook URL for the given local path. The cloud service will generate
|
||||
* a unique URL like {@code https://myopenhab.org/api/hooks/{uuid}} that proxies
|
||||
* incoming requests to the specified local path on this openHAB instance.
|
||||
*
|
||||
* <p>
|
||||
* This method is idempotent: calling it with the same {@code localPath} returns
|
||||
* the same webhook URL and refreshes the 30-day TTL.
|
||||
*
|
||||
* @param localPath the local openHAB path to forward webhook requests to
|
||||
* (e.g., {@code "/rest/webhook/netatmo"})
|
||||
* @return a {@link CompletableFuture} that completes with the full webhook URL,
|
||||
* or completes exceptionally if the cloud is not connected or registration fails
|
||||
*/
|
||||
CompletableFuture<String> requestWebhook(String localPath);
|
||||
|
||||
/**
|
||||
* Remove a previously registered webhook for the given local path.
|
||||
*
|
||||
* @param localPath the local openHAB path whose webhook should be removed
|
||||
* @return a {@link CompletableFuture} that completes when the webhook is removed,
|
||||
* or completes exceptionally if the cloud is not connected or removal fails
|
||||
*/
|
||||
CompletableFuture<Void> removeWebhook(String localPath);
|
||||
}
|
||||
+84
-7
@@ -20,6 +20,8 @@ import java.net.URL;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
@@ -70,6 +72,7 @@ import org.json.JSONObject;
|
||||
import org.openhab.core.OpenHAB;
|
||||
import org.openhab.core.common.ThreadPoolManager;
|
||||
import org.openhab.core.events.AbstractEvent;
|
||||
import org.openhab.core.io.rest.Webhook;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@@ -158,6 +161,20 @@ public class CloudClient {
|
||||
*/
|
||||
private boolean isConnected;
|
||||
|
||||
/*
|
||||
* Webhook emit operations queued while disconnected; drained on next onConnect()
|
||||
* or failed in shutdown().
|
||||
*/
|
||||
private record PendingWebhookEmit(CompletableFuture<?> future, Runnable emit) {
|
||||
}
|
||||
|
||||
private final ConcurrentLinkedDeque<PendingWebhookEmit> pendingWebhookEmits = new ConcurrentLinkedDeque<>();
|
||||
|
||||
/*
|
||||
* Max time a queued webhook operation will wait for the cloud to (re)connect before failing.
|
||||
*/
|
||||
private static final int WEBHOOK_QUEUE_WAIT_SECONDS = 60;
|
||||
|
||||
/*
|
||||
* This variable holds instance of Socket.IO client class which provides communication
|
||||
* with the openHAB Cloud
|
||||
@@ -408,6 +425,35 @@ public class CloudClient {
|
||||
this.localBaseUrl);
|
||||
reconnectBackoff.reset();
|
||||
isConnected = true;
|
||||
drainPendingWebhookEmits();
|
||||
}
|
||||
|
||||
private void drainPendingWebhookEmits() {
|
||||
if (pendingWebhookEmits.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
logger.debug("Draining {} queued webhook operation(s) after cloud connect", pendingWebhookEmits.size());
|
||||
PendingWebhookEmit pending;
|
||||
while ((pending = pendingWebhookEmits.poll()) != null) {
|
||||
try {
|
||||
pending.emit().run();
|
||||
} catch (RuntimeException e) {
|
||||
logger.debug("Queued webhook emit threw: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void failPendingWebhookEmits(String reason) {
|
||||
if (pendingWebhookEmits.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
logger.debug("Failing {} queued webhook operation(s): {}", pendingWebhookEmits.size(), reason);
|
||||
PendingWebhookEmit pending;
|
||||
while ((pending = pendingWebhookEmits.poll()) != null) {
|
||||
if (!pending.future().isDone()) {
|
||||
pending.future().completeExceptionally(new IOException(reason));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -878,13 +924,24 @@ public class CloudClient {
|
||||
* Register a webhook with the openHAB Cloud for the given local path.
|
||||
*
|
||||
* @param localPath the local path to forward webhook requests to
|
||||
* @param future the future to complete with the webhook URL or an error
|
||||
* @param future the future to complete with the {@link Webhook} or an error
|
||||
*/
|
||||
public void registerWebhook(String localPath, CompletableFuture<String> future) {
|
||||
emitWebhookEvent("webhook:register", localPath, future, response -> response.getString("webhookUrl"),
|
||||
protected void registerWebhook(String localPath, CompletableFuture<Webhook> future) {
|
||||
emitWebhookEvent("webhook:register", localPath, future, CloudClient::toWebhook,
|
||||
"Webhook registration timed out");
|
||||
}
|
||||
|
||||
private static Webhook toWebhook(JSONObject response) {
|
||||
try {
|
||||
URL url = new URL(response.getString("webhookUrl"));
|
||||
Instant expiresAt = response.has("expiresAt") ? Instant.parse(response.getString("expiresAt"))
|
||||
: Instant.now().plus(Duration.ofDays(30));
|
||||
return new Webhook(url, expiresAt);
|
||||
} catch (MalformedURLException e) {
|
||||
throw new IllegalArgumentException("Invalid webhook URL from openHAB Cloud", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a webhook from the openHAB Cloud for the given local path.
|
||||
*
|
||||
@@ -897,13 +954,28 @@ public class CloudClient {
|
||||
|
||||
private <T> void emitWebhookEvent(String eventName, String localPath, CompletableFuture<T> future,
|
||||
Function<JSONObject, T> successHandler, String timeoutMessage) {
|
||||
if (!isConnected()) {
|
||||
future.completeExceptionally(new IOException("Not connected to openHAB Cloud"));
|
||||
Runnable emit = () -> doEmitWebhookEvent(eventName, localPath, future, successHandler, timeoutMessage);
|
||||
if (isConnected()) {
|
||||
emit.run();
|
||||
return;
|
||||
}
|
||||
// Cloud connection is bouncing (e.g. during CloudService.modified()); queue and let onConnect drain.
|
||||
logger.debug("Queueing {} for localPath {} until cloud is connected", eventName, localPath);
|
||||
PendingWebhookEmit pending = new PendingWebhookEmit(future, emit);
|
||||
pendingWebhookEmits.add(pending);
|
||||
scheduler.schedule(() -> {
|
||||
if (pendingWebhookEmits.remove(pending) && !future.isDone()) {
|
||||
future.completeExceptionally(new IOException("Timed out waiting for cloud connection"));
|
||||
}
|
||||
}, WEBHOOK_QUEUE_WAIT_SECONDS, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private <T> void doEmitWebhookEvent(String eventName, String localPath, CompletableFuture<T> future,
|
||||
Function<JSONObject, T> successHandler, String timeoutMessage) {
|
||||
try {
|
||||
JSONObject data = new JSONObject();
|
||||
data.put("localPath", localPath);
|
||||
logger.debug("Emitting {} for localPath {}", eventName, localPath);
|
||||
socket.emit(eventName, data, (io.socket.client.Ack) args -> {
|
||||
try {
|
||||
if (args == null || args.length == 0 || !(args[0] instanceof JSONObject)) {
|
||||
@@ -911,12 +983,16 @@ public class CloudClient {
|
||||
return;
|
||||
}
|
||||
JSONObject response = (JSONObject) args[0];
|
||||
if (response.optBoolean("success")) {
|
||||
boolean success = response.optBoolean("success");
|
||||
logger.debug("{} for {} success={}{}", eventName, localPath, success,
|
||||
response.has("expiresAt") ? " expiresAt=" + response.optString("expiresAt") : "");
|
||||
if (success) {
|
||||
future.complete(successHandler.apply(response));
|
||||
} else {
|
||||
future.completeExceptionally(new IOException(response.optString("error", "Unknown error")));
|
||||
}
|
||||
} catch (JSONException | ClassCastException e) {
|
||||
} catch (RuntimeException e) {
|
||||
logger.debug("Failed to parse {} response for {}", eventName, localPath, e);
|
||||
future.completeExceptionally(new IOException("Invalid response from cloud", e));
|
||||
}
|
||||
});
|
||||
@@ -943,6 +1019,7 @@ public class CloudClient {
|
||||
public void shutdown() {
|
||||
logger.info("Shutting down openHAB Cloud service connection");
|
||||
reconnectFuture.get().ifPresent(future -> future.cancel(true));
|
||||
failPendingWebhookEmits("Cloud connector shut down");
|
||||
socket.disconnect();
|
||||
}
|
||||
|
||||
|
||||
+12
-9
@@ -35,6 +35,8 @@ import org.openhab.core.events.EventPublisher;
|
||||
import org.openhab.core.events.EventSubscriber;
|
||||
import org.openhab.core.id.InstanceUUID;
|
||||
import org.openhab.core.io.net.http.HttpClientFactory;
|
||||
import org.openhab.core.io.rest.Webhook;
|
||||
import org.openhab.core.io.rest.WebhookService;
|
||||
import org.openhab.core.items.Item;
|
||||
import org.openhab.core.items.ItemNotFoundException;
|
||||
import org.openhab.core.items.ItemRegistry;
|
||||
@@ -50,7 +52,6 @@ import org.openhab.core.types.Command;
|
||||
import org.openhab.core.types.TypeParser;
|
||||
import org.openhab.core.util.StringUtils;
|
||||
import org.openhab.io.openhabcloud.NotificationAction;
|
||||
import org.openhab.io.openhabcloud.WebhookService;
|
||||
import org.osgi.framework.BundleContext;
|
||||
import org.osgi.framework.Constants;
|
||||
import org.osgi.service.component.annotations.Activate;
|
||||
@@ -328,16 +329,17 @@ public class CloudService implements ActionService, CloudClientListener, EventSu
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<String> requestWebhook(String localPath) {
|
||||
CompletableFuture<String> future = new CompletableFuture<>();
|
||||
public CompletableFuture<Webhook> requestWebhook(String localPath) {
|
||||
CompletableFuture<Webhook> future = new CompletableFuture<>();
|
||||
if (localPath.isBlank() || !localPath.startsWith("/")) {
|
||||
future.completeExceptionally(new IllegalArgumentException("localPath must start with '/'"));
|
||||
return future;
|
||||
}
|
||||
if (cloudClient != null && cloudClient.isConnected()) {
|
||||
cloudClient.registerWebhook(localPath, future);
|
||||
CloudClient client = cloudClient;
|
||||
if (client != null) {
|
||||
client.registerWebhook(localPath, future);
|
||||
} else {
|
||||
future.completeExceptionally(new IllegalStateException("Cloud connector is not connected"));
|
||||
future.completeExceptionally(new IllegalStateException("Cloud connector is not initialized"));
|
||||
}
|
||||
return future;
|
||||
}
|
||||
@@ -349,10 +351,11 @@ public class CloudService implements ActionService, CloudClientListener, EventSu
|
||||
future.completeExceptionally(new IllegalArgumentException("localPath must start with '/'"));
|
||||
return future;
|
||||
}
|
||||
if (cloudClient != null && cloudClient.isConnected()) {
|
||||
cloudClient.removeWebhook(localPath, future);
|
||||
CloudClient client = cloudClient;
|
||||
if (client != null) {
|
||||
client.removeWebhook(localPath, future);
|
||||
} else {
|
||||
future.completeExceptionally(new IllegalStateException("Cloud connector is not connected"));
|
||||
future.completeExceptionally(new IllegalStateException("Cloud connector is not initialized"));
|
||||
}
|
||||
return future;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user