[openhabcloud] Provide core WebhookService (#20748)

* [openhabcloud] Provide core WebhookService

Signed-off-by: Dan Cunningham <dan@digitaldan.com>
This commit is contained in:
Dan Cunningham
2026-06-05 15:48:58 +02:00
committed by GitHub
parent 6243babac3
commit c6ef2e2fd7
4 changed files with 102 additions and 85 deletions
@@ -15,6 +15,12 @@
<name>openHAB Add-ons :: Bundles :: IO :: openHAB Cloud Connector</name> <name>openHAB Add-ons :: Bundles :: IO :: openHAB Cloud Connector</name>
<dependencies> <dependencies>
<dependency>
<groupId>org.openhab.core.bundles</groupId>
<artifactId>org.openhab.core.io.rest</artifactId>
<version>${project.version}</version>
<scope>provided</scope>
</dependency>
<dependency> <dependency>
<groupId>org.json</groupId> <groupId>org.json</groupId>
<artifactId>json</artifactId> <artifactId>json</artifactId>
@@ -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);
}
@@ -20,6 +20,8 @@ import java.net.URL;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.Collections; import java.util.Collections;
import java.util.Iterator; import java.util.Iterator;
import java.util.List; import java.util.List;
@@ -70,6 +72,7 @@ import org.json.JSONObject;
import org.openhab.core.OpenHAB; import org.openhab.core.OpenHAB;
import org.openhab.core.common.ThreadPoolManager; import org.openhab.core.common.ThreadPoolManager;
import org.openhab.core.events.AbstractEvent; import org.openhab.core.events.AbstractEvent;
import org.openhab.core.io.rest.Webhook;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -158,6 +161,20 @@ public class CloudClient {
*/ */
private boolean isConnected; 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 * This variable holds instance of Socket.IO client class which provides communication
* with the openHAB Cloud * with the openHAB Cloud
@@ -408,6 +425,35 @@ public class CloudClient {
this.localBaseUrl); this.localBaseUrl);
reconnectBackoff.reset(); reconnectBackoff.reset();
isConnected = true; 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. * Register a webhook with the openHAB Cloud for the given local path.
* *
* @param localPath the local path to forward webhook requests to * @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) { protected void registerWebhook(String localPath, CompletableFuture<Webhook> future) {
emitWebhookEvent("webhook:register", localPath, future, response -> response.getString("webhookUrl"), emitWebhookEvent("webhook:register", localPath, future, CloudClient::toWebhook,
"Webhook registration timed out"); "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. * 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, private <T> void emitWebhookEvent(String eventName, String localPath, CompletableFuture<T> future,
Function<JSONObject, T> successHandler, String timeoutMessage) { Function<JSONObject, T> successHandler, String timeoutMessage) {
if (!isConnected()) { Runnable emit = () -> doEmitWebhookEvent(eventName, localPath, future, successHandler, timeoutMessage);
future.completeExceptionally(new IOException("Not connected to openHAB Cloud")); if (isConnected()) {
emit.run();
return; 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 { try {
JSONObject data = new JSONObject(); JSONObject data = new JSONObject();
data.put("localPath", localPath); data.put("localPath", localPath);
logger.debug("Emitting {} for localPath {}", eventName, localPath);
socket.emit(eventName, data, (io.socket.client.Ack) args -> { socket.emit(eventName, data, (io.socket.client.Ack) args -> {
try { try {
if (args == null || args.length == 0 || !(args[0] instanceof JSONObject)) { if (args == null || args.length == 0 || !(args[0] instanceof JSONObject)) {
@@ -911,12 +983,16 @@ public class CloudClient {
return; return;
} }
JSONObject response = (JSONObject) args[0]; 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)); future.complete(successHandler.apply(response));
} else { } else {
future.completeExceptionally(new IOException(response.optString("error", "Unknown error"))); 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)); future.completeExceptionally(new IOException("Invalid response from cloud", e));
} }
}); });
@@ -943,6 +1019,7 @@ public class CloudClient {
public void shutdown() { public void shutdown() {
logger.info("Shutting down openHAB Cloud service connection"); logger.info("Shutting down openHAB Cloud service connection");
reconnectFuture.get().ifPresent(future -> future.cancel(true)); reconnectFuture.get().ifPresent(future -> future.cancel(true));
failPendingWebhookEmits("Cloud connector shut down");
socket.disconnect(); socket.disconnect();
} }
@@ -35,6 +35,8 @@ import org.openhab.core.events.EventPublisher;
import org.openhab.core.events.EventSubscriber; import org.openhab.core.events.EventSubscriber;
import org.openhab.core.id.InstanceUUID; import org.openhab.core.id.InstanceUUID;
import org.openhab.core.io.net.http.HttpClientFactory; 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.Item;
import org.openhab.core.items.ItemNotFoundException; import org.openhab.core.items.ItemNotFoundException;
import org.openhab.core.items.ItemRegistry; 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.types.TypeParser;
import org.openhab.core.util.StringUtils; import org.openhab.core.util.StringUtils;
import org.openhab.io.openhabcloud.NotificationAction; import org.openhab.io.openhabcloud.NotificationAction;
import org.openhab.io.openhabcloud.WebhookService;
import org.osgi.framework.BundleContext; import org.osgi.framework.BundleContext;
import org.osgi.framework.Constants; import org.osgi.framework.Constants;
import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Activate;
@@ -328,16 +329,17 @@ public class CloudService implements ActionService, CloudClientListener, EventSu
} }
@Override @Override
public CompletableFuture<String> requestWebhook(String localPath) { public CompletableFuture<Webhook> requestWebhook(String localPath) {
CompletableFuture<String> future = new CompletableFuture<>(); CompletableFuture<Webhook> future = new CompletableFuture<>();
if (localPath.isBlank() || !localPath.startsWith("/")) { if (localPath.isBlank() || !localPath.startsWith("/")) {
future.completeExceptionally(new IllegalArgumentException("localPath must start with '/'")); future.completeExceptionally(new IllegalArgumentException("localPath must start with '/'"));
return future; return future;
} }
if (cloudClient != null && cloudClient.isConnected()) { CloudClient client = cloudClient;
cloudClient.registerWebhook(localPath, future); if (client != null) {
client.registerWebhook(localPath, future);
} else { } else {
future.completeExceptionally(new IllegalStateException("Cloud connector is not connected")); future.completeExceptionally(new IllegalStateException("Cloud connector is not initialized"));
} }
return future; return future;
} }
@@ -349,10 +351,11 @@ public class CloudService implements ActionService, CloudClientListener, EventSu
future.completeExceptionally(new IllegalArgumentException("localPath must start with '/'")); future.completeExceptionally(new IllegalArgumentException("localPath must start with '/'"));
return future; return future;
} }
if (cloudClient != null && cloudClient.isConnected()) { CloudClient client = cloudClient;
cloudClient.removeWebhook(localPath, future); if (client != null) {
client.removeWebhook(localPath, future);
} else { } else {
future.completeExceptionally(new IllegalStateException("Cloud connector is not connected")); future.completeExceptionally(new IllegalStateException("Cloud connector is not initialized"));
} }
return future; return future;
} }