Skip to content

Commit 4dcd66e

Browse files
authored
Local webhook testing support (#145)
This PR enables support for local webhook development. When implementing this feature in your app, the SDK will create a tunnel connection to a websocket server and registers it as a webhook callback to your Nylas account.
1 parent 6bb9f6c commit 4dcd66e

4 files changed

Lines changed: 254 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ This section contains changes that have been committed but not yet released.
66

77
### Added
88

9+
* Added local webhook testing support
10+
* Added new enums for `Webhook.Triggers` and `Webhook.State`
11+
912
### Changed
1013

1114
### Deprecated

build.gradle

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,10 @@ dependencies {
3939

4040
// SLF4J for logging facade
4141
implementation('org.slf4j:slf4j-api:1.7.30')
42-
42+
43+
// Websocket dependency
44+
implementation('org.java-websocket:Java-WebSocket:1.5.3')
45+
4346
///////////////////////////////////
4447
// Test dependencies
4548

src/main/java/com/nylas/Webhook.java

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import java.util.HashMap;
44
import java.util.List;
55
import java.util.Map;
6+
import java.util.stream.Collectors;
7+
import java.util.stream.Stream;
68

79
public class Webhook extends RestfulModel {
810

@@ -11,6 +13,49 @@ public class Webhook extends RestfulModel {
1113
private String state;
1214
private List<String> triggers;
1315
private String version;
16+
17+
/**
18+
* Enumeration containing the different webhook triggers
19+
*/
20+
public enum Trigger {
21+
AccountConnected("account.connected"),
22+
AccountRunning("account.running"),
23+
AccountStopped("account.stopped"),
24+
AccountInvalid("account.invalid"),
25+
AccountSyncError("account.sync_error"),
26+
MessageBounced("message.bounced"),
27+
MessageCreated("message.created"),
28+
MessageOpened("message.opened"),
29+
MessageUpdated("message.updated"),
30+
MessageLinkClicked("message.link_clicked"),
31+
ThreadReplied("thread.replied"),
32+
ContactCreated("contact.created"),
33+
ContactUpdated("contact.updated"),
34+
ContactDeleted("contact.deleted"),
35+
CalendarCreated("calendar.created"),
36+
CalendarUpdated("calendar.updated"),
37+
CalendarDeleted("calendar.deleted"),
38+
EventCreated("event.created"),
39+
EventUpdated("event.updated"),
40+
EventDeleted("event.deleted"),
41+
JobSuccessful("job.successful"),
42+
JobFailed("job.failed");
43+
44+
private final String name;
45+
46+
Trigger(String name) {
47+
this.name = name;
48+
}
49+
50+
public String getName() {
51+
return name;
52+
}
53+
}
54+
55+
public enum State {
56+
ACTIVE,
57+
INACTIVE;
58+
}
1459

1560
public String getApplicationId() {
1661
return application_id;
@@ -40,10 +85,20 @@ public void setState(String state) {
4085
this.state = state;
4186
}
4287

88+
public void setState(State state) {
89+
this.state = state.toString().toLowerCase();
90+
}
91+
4392
public void setTriggers(List<String> triggers) {
4493
this.triggers = triggers;
4594
}
4695

96+
public void setTriggers(Trigger... triggers) {
97+
this.triggers = Stream.of(triggers)
98+
.map(Trigger::name)
99+
.collect(Collectors.toList());
100+
}
101+
47102
@Override
48103
public String toString() {
49104
return "Webhook [application_id=" + application_id + ", callback_url=" + callback_url + ", state=" + state
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
package com.nylas.services;
2+
3+
import com.nylas.*;
4+
import org.java_websocket.client.WebSocketClient;
5+
import org.java_websocket.handshake.ServerHandshake;
6+
import org.slf4j.Logger;
7+
import org.slf4j.LoggerFactory;
8+
9+
import java.io.IOException;
10+
import java.net.URI;
11+
import java.net.URISyntaxException;
12+
import java.util.*;
13+
import java.util.stream.Collectors;
14+
import java.util.stream.Stream;
15+
16+
/**
17+
* Sets up and registers a websocket connection for local webhook testing
18+
*/
19+
public class Tunnel extends WebSocketClient {
20+
21+
private final NylasApplication app;
22+
private final String tunnelId;
23+
private final WebhookHandler webhookHandler;
24+
private final List<String> triggers;
25+
private final String region;
26+
private static final String websocketDomain = "tunnel.nylas.com";
27+
private static final String callbackDomain = "cb.nylas.com";
28+
private static final Logger log = LoggerFactory.getLogger(Tunnel.class);
29+
30+
public Tunnel(Builder builder) throws URISyntaxException {
31+
super(new URI("wss://" + websocketDomain));
32+
this.webhookHandler = builder.webhookHandler;
33+
this.app = builder.app;
34+
this.tunnelId = UUID.randomUUID().toString();
35+
this.triggers = builder.triggers;
36+
this.region = builder.region;
37+
this.setHeaders();
38+
}
39+
40+
/**
41+
* {@inheritDoc}
42+
* Also registers the webhook with the Nylas API.
43+
*/
44+
@Override
45+
public void connect() {
46+
super.connect();
47+
try {
48+
registerWebhookCallback(app, tunnelId, triggers);
49+
} catch (RequestFailedException | IOException e) {
50+
log.trace("Error encountered while trying to register webhook with the Nylas API");
51+
throw new RuntimeException(e);
52+
}
53+
}
54+
55+
/**
56+
* {@inheritDoc}
57+
* Calls {@link WebhookHandler#onOpen(short)}
58+
*/
59+
@Override
60+
public void onOpen(ServerHandshake handshakedata) {
61+
log.trace("Opening websocket connection");
62+
webhookHandler.onOpen(handshakedata.getHttpStatus());
63+
}
64+
65+
/**
66+
* {@inheritDoc}
67+
* Calls {@link WebhookHandler#onMessage(Notification)}
68+
*/
69+
@Override
70+
public void onMessage(String message) {
71+
log.trace("Messaged received from websocket");
72+
Map<String, Object> response = JsonHelper.jsonToMap(message);
73+
String jsonBody = (String) response.get("body");
74+
if(jsonBody == null || jsonBody.isEmpty()) {
75+
log.trace("Not a valid delta response, skipping.");
76+
return;
77+
}
78+
79+
// Parse notification from JSON body and call onMessage callback
80+
Notification notification = Notification.parseNotification(jsonBody);
81+
webhookHandler.onMessage(notification);
82+
}
83+
84+
/**
85+
* {@inheritDoc}
86+
* Calls {@link WebhookHandler#onClose(int, String, boolean)}
87+
*/
88+
@Override
89+
public void onClose(int code, String reason, boolean remote) {
90+
log.trace("Closing websocket connection");
91+
webhookHandler.onClose(code, reason, remote);
92+
}
93+
94+
/**
95+
* {@inheritDoc}
96+
* Calls {@link WebhookHandler#onError(Exception)}
97+
*/
98+
@Override
99+
public void onError(Exception ex) {
100+
log.trace("Error encountered during websocket connection");
101+
webhookHandler.onError(ex);
102+
}
103+
104+
/**
105+
* Sets the headers necessary for setting up the websocket tunnel
106+
*/
107+
private void setHeaders() {
108+
this.addHeader("Client-Id", app.getClientId());
109+
this.addHeader("Client-Secret", app.getClientSecret());
110+
this.addHeader("Tunnel-Id", tunnelId);
111+
this.addHeader("Region", region);
112+
}
113+
114+
/**
115+
* Registers the websocket connection and the callback with your Nylas application
116+
* @param app The configured Nylas application
117+
* @param tunnelId The UUID generated for the websocket tunnel
118+
* @param triggers The triggers to subscribe to
119+
*/
120+
private void registerWebhookCallback(NylasApplication app, String tunnelId, List<String> triggers)
121+
throws RequestFailedException, IOException {
122+
Webhook webhook = new Webhook();
123+
webhook.setCallbackUrl(String.format("https://%s/%s", callbackDomain, tunnelId));
124+
webhook.setState(Webhook.State.ACTIVE);
125+
webhook.setTriggers(triggers);
126+
app.webhooks().create(webhook);
127+
}
128+
129+
/**
130+
* A builder for {@link Tunnel}
131+
*/
132+
public static class Builder {
133+
private final NylasApplication app;
134+
private final WebhookHandler webhookHandler;
135+
private String region = "us";
136+
private List<String> triggers = convertTriggersToString(Webhook.Trigger.values());
137+
138+
public Builder(NylasApplication app, WebhookHandler webhookHandler) {
139+
this.app = app;
140+
this.webhookHandler = webhookHandler;
141+
}
142+
143+
/**
144+
* Set the region to configure the websocket to
145+
* @param region The Nylas region
146+
* @return The builder with the region set
147+
*/
148+
public Builder region(String region) {
149+
this.region = region;
150+
return this;
151+
}
152+
153+
/**
154+
* Set the webhook trigger(s) to subscribe to
155+
* @param triggers The webhook trigger(s) to subscribe to
156+
* @return The builder with the trigger(s) set
157+
*/
158+
public Builder triggers(Webhook.Trigger... triggers) {
159+
this.triggers = convertTriggersToString(triggers);
160+
return this;
161+
}
162+
163+
/**
164+
* Builds the Tunnel
165+
* @return The configured Tunnel
166+
*/
167+
public Tunnel build() throws URISyntaxException {
168+
return new Tunnel(this);
169+
}
170+
171+
/**
172+
* Helper method that converts a list of triggers to their string values
173+
* @param triggers The list of triggers to convert
174+
* @return A list of strings with the value of the provided triggers
175+
*/
176+
private static List<String> convertTriggersToString(Webhook.Trigger[] triggers) {
177+
return Stream.of(triggers)
178+
.map(Webhook.Trigger::getName)
179+
.collect(Collectors.toList());
180+
}
181+
}
182+
183+
/**
184+
* An interface for implementing classes to handle events from the {@link Tunnel}
185+
*/
186+
public interface WebhookHandler {
187+
void onOpen(short httpStatusCode);
188+
void onClose(int code, String reason, boolean remote);
189+
void onMessage(Notification notification);
190+
void onError(Exception ex);
191+
}
192+
}

0 commit comments

Comments
 (0)