Skip to content

Commit

Permalink
Local webhook testing support (#145)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
mrashed-dev committed Feb 6, 2023
1 parent 6bb9f6c commit 4dcd66e
Show file tree
Hide file tree
Showing 4 changed files with 254 additions and 1 deletion.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ This section contains changes that have been committed but not yet released.

### Added

* Added local webhook testing support
* Added new enums for `Webhook.Triggers` and `Webhook.State`

### Changed

### Deprecated
Expand Down
5 changes: 4 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ dependencies {

// SLF4J for logging facade
implementation('org.slf4j:slf4j-api:1.7.30')


// Websocket dependency
implementation('org.java-websocket:Java-WebSocket:1.5.3')

///////////////////////////////////
// Test dependencies

Expand Down
55 changes: 55 additions & 0 deletions src/main/java/com/nylas/Webhook.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class Webhook extends RestfulModel {

Expand All @@ -11,6 +13,49 @@ public class Webhook extends RestfulModel {
private String state;
private List<String> triggers;
private String version;

/**
* Enumeration containing the different webhook triggers
*/
public enum Trigger {
AccountConnected("account.connected"),
AccountRunning("account.running"),
AccountStopped("account.stopped"),
AccountInvalid("account.invalid"),
AccountSyncError("account.sync_error"),
MessageBounced("message.bounced"),
MessageCreated("message.created"),
MessageOpened("message.opened"),
MessageUpdated("message.updated"),
MessageLinkClicked("message.link_clicked"),
ThreadReplied("thread.replied"),
ContactCreated("contact.created"),
ContactUpdated("contact.updated"),
ContactDeleted("contact.deleted"),
CalendarCreated("calendar.created"),
CalendarUpdated("calendar.updated"),
CalendarDeleted("calendar.deleted"),
EventCreated("event.created"),
EventUpdated("event.updated"),
EventDeleted("event.deleted"),
JobSuccessful("job.successful"),
JobFailed("job.failed");

private final String name;

Trigger(String name) {
this.name = name;
}

public String getName() {
return name;
}
}

public enum State {
ACTIVE,
INACTIVE;
}

public String getApplicationId() {
return application_id;
Expand Down Expand Up @@ -40,10 +85,20 @@ public void setState(String state) {
this.state = state;
}

public void setState(State state) {
this.state = state.toString().toLowerCase();
}

public void setTriggers(List<String> triggers) {
this.triggers = triggers;
}

public void setTriggers(Trigger... triggers) {
this.triggers = Stream.of(triggers)
.map(Trigger::name)
.collect(Collectors.toList());
}

@Override
public String toString() {
return "Webhook [application_id=" + application_id + ", callback_url=" + callback_url + ", state=" + state
Expand Down
192 changes: 192 additions & 0 deletions src/main/java/com/nylas/services/Tunnel.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
package com.nylas.services;

import com.nylas.*;
import org.java_websocket.client.WebSocketClient;
import org.java_websocket.handshake.ServerHandshake;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
* Sets up and registers a websocket connection for local webhook testing
*/
public class Tunnel extends WebSocketClient {

private final NylasApplication app;
private final String tunnelId;
private final WebhookHandler webhookHandler;
private final List<String> triggers;
private final String region;
private static final String websocketDomain = "tunnel.nylas.com";
private static final String callbackDomain = "cb.nylas.com";
private static final Logger log = LoggerFactory.getLogger(Tunnel.class);

public Tunnel(Builder builder) throws URISyntaxException {
super(new URI("wss://" + websocketDomain));
this.webhookHandler = builder.webhookHandler;
this.app = builder.app;
this.tunnelId = UUID.randomUUID().toString();
this.triggers = builder.triggers;
this.region = builder.region;
this.setHeaders();
}

/**
* {@inheritDoc}
* Also registers the webhook with the Nylas API.
*/
@Override
public void connect() {
super.connect();
try {
registerWebhookCallback(app, tunnelId, triggers);
} catch (RequestFailedException | IOException e) {
log.trace("Error encountered while trying to register webhook with the Nylas API");
throw new RuntimeException(e);
}
}

/**
* {@inheritDoc}
* Calls {@link WebhookHandler#onOpen(short)}
*/
@Override
public void onOpen(ServerHandshake handshakedata) {
log.trace("Opening websocket connection");
webhookHandler.onOpen(handshakedata.getHttpStatus());
}

/**
* {@inheritDoc}
* Calls {@link WebhookHandler#onMessage(Notification)}
*/
@Override
public void onMessage(String message) {
log.trace("Messaged received from websocket");
Map<String, Object> response = JsonHelper.jsonToMap(message);
String jsonBody = (String) response.get("body");
if(jsonBody == null || jsonBody.isEmpty()) {
log.trace("Not a valid delta response, skipping.");
return;
}

// Parse notification from JSON body and call onMessage callback
Notification notification = Notification.parseNotification(jsonBody);
webhookHandler.onMessage(notification);
}

/**
* {@inheritDoc}
* Calls {@link WebhookHandler#onClose(int, String, boolean)}
*/
@Override
public void onClose(int code, String reason, boolean remote) {
log.trace("Closing websocket connection");
webhookHandler.onClose(code, reason, remote);
}

/**
* {@inheritDoc}
* Calls {@link WebhookHandler#onError(Exception)}
*/
@Override
public void onError(Exception ex) {
log.trace("Error encountered during websocket connection");
webhookHandler.onError(ex);
}

/**
* Sets the headers necessary for setting up the websocket tunnel
*/
private void setHeaders() {
this.addHeader("Client-Id", app.getClientId());
this.addHeader("Client-Secret", app.getClientSecret());
this.addHeader("Tunnel-Id", tunnelId);
this.addHeader("Region", region);
}

/**
* Registers the websocket connection and the callback with your Nylas application
* @param app The configured Nylas application
* @param tunnelId The UUID generated for the websocket tunnel
* @param triggers The triggers to subscribe to
*/
private void registerWebhookCallback(NylasApplication app, String tunnelId, List<String> triggers)
throws RequestFailedException, IOException {
Webhook webhook = new Webhook();
webhook.setCallbackUrl(String.format("https://%s/%s", callbackDomain, tunnelId));
webhook.setState(Webhook.State.ACTIVE);
webhook.setTriggers(triggers);
app.webhooks().create(webhook);
}

/**
* A builder for {@link Tunnel}
*/
public static class Builder {
private final NylasApplication app;
private final WebhookHandler webhookHandler;
private String region = "us";
private List<String> triggers = convertTriggersToString(Webhook.Trigger.values());

public Builder(NylasApplication app, WebhookHandler webhookHandler) {
this.app = app;
this.webhookHandler = webhookHandler;
}

/**
* Set the region to configure the websocket to
* @param region The Nylas region
* @return The builder with the region set
*/
public Builder region(String region) {
this.region = region;
return this;
}

/**
* Set the webhook trigger(s) to subscribe to
* @param triggers The webhook trigger(s) to subscribe to
* @return The builder with the trigger(s) set
*/
public Builder triggers(Webhook.Trigger... triggers) {
this.triggers = convertTriggersToString(triggers);
return this;
}

/**
* Builds the Tunnel
* @return The configured Tunnel
*/
public Tunnel build() throws URISyntaxException {
return new Tunnel(this);
}

/**
* Helper method that converts a list of triggers to their string values
* @param triggers The list of triggers to convert
* @return A list of strings with the value of the provided triggers
*/
private static List<String> convertTriggersToString(Webhook.Trigger[] triggers) {
return Stream.of(triggers)
.map(Webhook.Trigger::getName)
.collect(Collectors.toList());
}
}

/**
* An interface for implementing classes to handle events from the {@link Tunnel}
*/
public interface WebhookHandler {
void onOpen(short httpStatusCode);
void onClose(int code, String reason, boolean remote);
void onMessage(Notification notification);
void onError(Exception ex);
}
}

0 comments on commit 4dcd66e

Please sign in to comment.