Skip to content

Commit 4c6164a

Browse files
digitaldanmarkus7017
authored andcommitted
[openhabcloud] Adds webhook service (openhab#20486)
* Adds webhook service Signed-off-by: Dan Cunningham <dan@digitaldan.com>
1 parent 1e869c6 commit 4c6164a

3 files changed

Lines changed: 172 additions & 11 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/*
2+
* Copyright (c) 2010-2026 Contributors to the openHAB project
3+
*
4+
* See the NOTICE file(s) distributed with this work for additional
5+
* information.
6+
*
7+
* This program and the accompanying materials are made available under the
8+
* terms of the Eclipse Public License 2.0 which is available at
9+
* http://www.eclipse.org/legal/epl-2.0
10+
*
11+
* SPDX-License-Identifier: EPL-2.0
12+
*/
13+
package org.openhab.io.openhabcloud;
14+
15+
import java.util.concurrent.CompletableFuture;
16+
17+
import org.eclipse.jdt.annotation.NonNullByDefault;
18+
19+
/**
20+
* Service interface for requesting webhook URLs from the openHAB Cloud.
21+
*
22+
* Other bindings can consume this service via OSGi {@code @Reference} to obtain
23+
* publicly-reachable webhook URLs. When an external service calls the webhook URL,
24+
* the cloud proxies the request to the specified local path on this openHAB instance.
25+
*
26+
* <p>
27+
* Usage example:
28+
*
29+
* <pre>
30+
* {@code @Reference(cardinality = ReferenceCardinality.OPTIONAL, policy = ReferencePolicy.DYNAMIC)}
31+
* protected void setWebhookService(WebhookService service) {
32+
* this.webhookService = service;
33+
* }
34+
*
35+
* // In initialize():
36+
* webhookService.requestWebhook("/myBinding/callback")
37+
* .thenAccept(url -> externalApi.registerCallback(url));
38+
* </pre>
39+
*
40+
* @author Dan Cunningham - Initial contribution
41+
*/
42+
@NonNullByDefault
43+
public interface WebhookService {
44+
45+
/**
46+
* Request a webhook URL for the given local path. The cloud service will generate
47+
* a unique URL like {@code https://myopenhab.org/api/hooks/{uuid}} that proxies
48+
* incoming requests to the specified local path on this openHAB instance.
49+
*
50+
* <p>
51+
* This method is idempotent: calling it with the same {@code localPath} returns
52+
* the same webhook URL and refreshes the 30-day TTL.
53+
*
54+
* @param localPath the local openHAB path to forward webhook requests to
55+
* (e.g., {@code "/rest/webhook/netatmo"})
56+
* @return a {@link CompletableFuture} that completes with the full webhook URL,
57+
* or completes exceptionally if the cloud is not connected or registration fails
58+
*/
59+
CompletableFuture<String> requestWebhook(String localPath);
60+
61+
/**
62+
* Remove a previously registered webhook for the given local path.
63+
*
64+
* @param localPath the local openHAB path whose webhook should be removed
65+
* @return a {@link CompletableFuture} that completes when the webhook is removed,
66+
* or completes exceptionally if the cloud is not connected or removal fails
67+
*/
68+
CompletableFuture<Void> removeWebhook(String localPath);
69+
}

bundles/org.openhab.io.openhabcloud/src/main/java/org/openhab/io/openhabcloud/internal/CloudClient.java

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,15 @@
2727
import java.util.Optional;
2828
import java.util.Set;
2929
import java.util.WeakHashMap;
30+
import java.util.concurrent.CompletableFuture;
3031
import java.util.concurrent.ConcurrentHashMap;
3132
import java.util.concurrent.ConcurrentLinkedDeque;
3233
import java.util.concurrent.ScheduledExecutorService;
3334
import java.util.concurrent.ScheduledFuture;
3435
import java.util.concurrent.TimeUnit;
3536
import java.util.concurrent.atomic.AtomicBoolean;
3637
import java.util.concurrent.atomic.AtomicReference;
38+
import java.util.function.Function;
3739
import java.util.function.Supplier;
3840

3941
import org.eclipse.jdt.annotation.Nullable;
@@ -510,14 +512,16 @@ private void handleRequestEvent(JSONObject data) {
510512

511513
Iterator<String> queryIterator = requestQueryJson.keys();
512514
// Add query parameters to URI builder, if any
513-
newPath += "?";
514-
while (queryIterator.hasNext()) {
515-
String queryName = queryIterator.next();
516-
newPath += queryName;
517-
newPath += "=";
518-
newPath += URLEncoder.encode(requestQueryJson.getString(queryName), "UTF-8");
519-
if (queryIterator.hasNext()) {
520-
newPath += "&";
515+
if (queryIterator.hasNext()) {
516+
newPath += "?";
517+
while (queryIterator.hasNext()) {
518+
String queryName = queryIterator.next();
519+
newPath += queryName;
520+
newPath += "=";
521+
newPath += URLEncoder.encode(requestQueryJson.getString(queryName), "UTF-8");
522+
if (queryIterator.hasNext()) {
523+
newPath += "&";
524+
}
521525
}
522526
}
523527
// Finally get the future request URI
@@ -870,6 +874,62 @@ public void sendItemUpdate(String itemName, String itemState) {
870874
}
871875
}
872876

877+
/**
878+
* Register a webhook with the openHAB Cloud for the given local path.
879+
*
880+
* @param localPath the local path to forward webhook requests to
881+
* @param future the future to complete with the webhook URL or an error
882+
*/
883+
public void registerWebhook(String localPath, CompletableFuture<String> future) {
884+
emitWebhookEvent("webhook:register", localPath, future, response -> response.getString("webhookUrl"),
885+
"Webhook registration timed out");
886+
}
887+
888+
/**
889+
* Remove a webhook from the openHAB Cloud for the given local path.
890+
*
891+
* @param localPath the local path whose webhook should be removed
892+
* @param future the future to complete when the webhook is removed or on error
893+
*/
894+
public void removeWebhook(String localPath, CompletableFuture<Void> future) {
895+
emitWebhookEvent("webhook:remove", localPath, future, response -> null, "Webhook removal timed out");
896+
}
897+
898+
private <T> void emitWebhookEvent(String eventName, String localPath, CompletableFuture<T> future,
899+
Function<JSONObject, T> successHandler, String timeoutMessage) {
900+
if (!isConnected()) {
901+
future.completeExceptionally(new IOException("Not connected to openHAB Cloud"));
902+
return;
903+
}
904+
try {
905+
JSONObject data = new JSONObject();
906+
data.put("localPath", localPath);
907+
socket.emit(eventName, data, (io.socket.client.Ack) args -> {
908+
try {
909+
if (args == null || args.length == 0 || !(args[0] instanceof JSONObject)) {
910+
future.completeExceptionally(new IOException("Missing or invalid response from openHAB Cloud"));
911+
return;
912+
}
913+
JSONObject response = (JSONObject) args[0];
914+
if (response.optBoolean("success")) {
915+
future.complete(successHandler.apply(response));
916+
} else {
917+
future.completeExceptionally(new IOException(response.optString("error", "Unknown error")));
918+
}
919+
} catch (JSONException | ClassCastException e) {
920+
future.completeExceptionally(new IOException("Invalid response from cloud", e));
921+
}
922+
});
923+
scheduler.schedule(() -> {
924+
if (!future.isDone()) {
925+
future.completeExceptionally(new IOException(timeoutMessage));
926+
}
927+
}, 30, TimeUnit.SECONDS);
928+
} catch (JSONException e) {
929+
future.completeExceptionally(new IOException("Failed to build webhook request", e));
930+
}
931+
}
932+
873933
/**
874934
* Returns true if openHAB Cloud connection is active
875935
*/

bundles/org.openhab.io.openhabcloud/src/main/java/org/openhab/io/openhabcloud/internal/CloudService.java

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import java.util.List;
2323
import java.util.Map;
2424
import java.util.Set;
25+
import java.util.concurrent.CompletableFuture;
2526

2627
import org.eclipse.jdt.annotation.Nullable;
2728
import org.eclipse.jetty.client.HttpClient;
@@ -49,6 +50,7 @@
4950
import org.openhab.core.types.TypeParser;
5051
import org.openhab.core.util.StringUtils;
5152
import org.openhab.io.openhabcloud.NotificationAction;
53+
import org.openhab.io.openhabcloud.WebhookService;
5254
import org.osgi.framework.BundleContext;
5355
import org.osgi.framework.Constants;
5456
import org.osgi.service.component.annotations.Activate;
@@ -66,11 +68,11 @@
6668
* @author Kai Kreuzer - migrated code to new Jetty client and ESH APIs
6769
* @author Dan Cunningham - Extended notification enhancements
6870
*/
69-
@Component(service = { CloudService.class, EventSubscriber.class,
70-
ActionService.class }, configurationPid = "org.openhab.openhabcloud", property = Constants.SERVICE_PID
71+
@Component(service = { CloudService.class, EventSubscriber.class, ActionService.class,
72+
WebhookService.class }, configurationPid = "org.openhab.openhabcloud", property = Constants.SERVICE_PID
7173
+ "=org.openhab.openhabcloud")
7274
@ConfigurableService(category = "io", label = "openHAB Cloud", description_uri = CloudService.CONFIG_URI)
73-
public class CloudService implements ActionService, CloudClientListener, EventSubscriber {
75+
public class CloudService implements ActionService, CloudClientListener, EventSubscriber, WebhookService {
7476

7577
protected static final String CONFIG_URI = "io:openhabcloud";
7678

@@ -325,6 +327,36 @@ protected void modified(Map<String, ?> config) {
325327
NotificationAction.setCloudService(this);
326328
}
327329

330+
@Override
331+
public CompletableFuture<String> requestWebhook(String localPath) {
332+
CompletableFuture<String> future = new CompletableFuture<>();
333+
if (localPath.isBlank() || !localPath.startsWith("/")) {
334+
future.completeExceptionally(new IllegalArgumentException("localPath must start with '/'"));
335+
return future;
336+
}
337+
if (cloudClient != null && cloudClient.isConnected()) {
338+
cloudClient.registerWebhook(localPath, future);
339+
} else {
340+
future.completeExceptionally(new IllegalStateException("Cloud connector is not connected"));
341+
}
342+
return future;
343+
}
344+
345+
@Override
346+
public CompletableFuture<Void> removeWebhook(String localPath) {
347+
CompletableFuture<Void> future = new CompletableFuture<>();
348+
if (localPath.isBlank() || !localPath.startsWith("/")) {
349+
future.completeExceptionally(new IllegalArgumentException("localPath must start with '/'"));
350+
return future;
351+
}
352+
if (cloudClient != null && cloudClient.isConnected()) {
353+
cloudClient.removeWebhook(localPath, future);
354+
} else {
355+
future.completeExceptionally(new IllegalStateException("Cloud connector is not connected"));
356+
}
357+
return future;
358+
}
359+
328360
@Override
329361
public String getActionClassName() {
330362
return NotificationAction.class.getCanonicalName();

0 commit comments

Comments
 (0)