package com.genesys.wsclient;
import java.io.UnsupportedEncodingException;
import java.util.HashMap;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executor;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.atomic.AtomicReference;
import org.cometd.bayeux.Message;
import org.cometd.bayeux.client.ClientSessionChannel;
import org.cometd.bayeux.client.ClientSessionChannel.MessageListener;
import org.cometd.client.BayeuxClient;
import org.cometd.client.transport.LongPollingTransport;
import org.cometd.websocket.client.WebSocketTransport;
import org.eclipse.jetty.client.ContentExchange;
import org.eclipse.jetty.client.HttpExchange;
import org.eclipse.jetty.websocket.WebSocketClientFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.genesys.wsclient.impl.Authentication;
import com.genesys.wsclient.impl.CookieSession;
import com.genesys.wsclient.impl.Jetty769HttpRequest;
import com.genesys.wsclient.impl.Jetty769Util;
import com.genesys.wsclient.impl.JsonUtil;
/**
* Use this class in order to receive Genesys Web Services events.
*
* <p>Subscriptions to events are re-subscribed on every reconnection to the server.
*
* <p>In order to create an instance of this class, call {@link GenesysClient#setupEventReceiver()},
* setup its parameters, and call {@link Setup#create()}.
*
* <p>This is an example of a minimal setup:
* <pre>
* GenesysClient client = ...;
* GenesysEventReceiver eventReceiver = client.setupEventReceiver()
* .eventExecutor(eventExecutor)
* .create();
* </pre>
*
* <p>This class is thread-safe.
*/
//TODO Upgrade to cometd-java-client 3.0.0. Currently in beta2.
//(This implies upgrading the Jetty Client, so there are more changes to do than just this class)
public class GenesysEventReceiver implements AutoCloseable {
private static final Logger LOG = LoggerFactory.getLogger(GenesysEventReceiver.class);
private static final Logger LOG_EVENT;
private static final Logger LOG_EVENT_CONTENT_RAW;
private static final Logger LOG_EVENT_CONTENT_PRETTY;
private static final Logger LOG_EVENT_TRANSPORT;
private static final Logger LOG_EVENT_TRANSPORT_HEADERS;
static {
String packageName = GenesysEventReceiver.class.getPackage().getName();
LOG_EVENT = LoggerFactory.getLogger(packageName + ".EVENT");
LOG_EVENT_CONTENT_RAW = LoggerFactory.getLogger(packageName + ".EVENT.CONTENT.RAW");
LOG_EVENT_CONTENT_PRETTY = LoggerFactory.getLogger(packageName + ".EVENT.CONTENT.PRETTY");
LOG_EVENT_TRANSPORT = LoggerFactory.getLogger(packageName + ".EVENT.TRANSPORT");
LOG_EVENT_TRANSPORT_HEADERS = LoggerFactory.getLogger(packageName + ".EVENT.TRANSPORT.HEADERS");
}
private final BayeuxClient bayeuxClient;
private final Executor eventExecutor;
private final List<Subscription> subscriptions = new CopyOnWriteArrayList<>();
/** Synchronize on bayeuxClient for access. */
private boolean isConnected;
private AtomicReference<Thread> subscriberLoopThread = new AtomicReference<>();
protected GenesysEventReceiver(Setup builder) {
this.bayeuxClient = createBayeuxClient(builder);
this.eventExecutor = builder.eventExecutor;
}
private static BayeuxClient createBayeuxClient(Setup builder) {
final Authentication authentication = builder.authentication;
HashMap<String, Object> longPollingOptions = new HashMap<>();
LongPollingTransport longPollingTransport = new LongPollingTransport(longPollingOptions, builder.client.httpClient) {
@Override protected void customize(ContentExchange exchange) {
super.customize(exchange);
HttpRequest request = new Jetty769HttpRequest(exchange);
authentication.setupRequest(request);
logRequest(exchange);
}
@Override protected void debug(String message, Object... args) {
LOG_EVENT_TRANSPORT.debug(message, args);
}
};
// Transports are set the cookieProvider *after* creating the bayeuxClient, because
// BayeuxClient invariably sets its own cookieProvider inside the constructor.
BayeuxClient bayeuxClient;
if (builder.webSocketEnabled) {
WebSocketTransport webSocketTransport = createWebSocketTransport();
bayeuxClient = new BayeuxClient(builder.client.serverUri + "/api/v2/notifications", webSocketTransport, longPollingTransport);
webSocketTransport.setCookieProvider(builder.cookieSession.getCookieProvider());
} else {
bayeuxClient = new BayeuxClient(builder.client.serverUri + "/api/v2/notifications", longPollingTransport);
}
longPollingTransport.setCookieProvider(builder.cookieSession.getCookieProvider());
return bayeuxClient;
}
private static WebSocketTransport createWebSocketTransport() {
HashMap<String, Object> webSocketOptions = new HashMap<>();
WebSocketClientFactory webSocketClientFactory = new WebSocketClientFactory();
try {
webSocketClientFactory.start();
} catch (Exception e) {
// Can't happen as WebSocketClientFactory.start() doesn't throw exceptions.
throw new AssertionError(e);
}
ScheduledExecutorService scheduler = null;
WebSocketTransport webSocketTransport = new WebSocketTransport(webSocketOptions, webSocketClientFactory, scheduler);
return webSocketTransport;
}
/** Starts communication with the server. */
public void open() {
Thread thread = new Thread(new Runnable() {
@Override public void run() {
subscriberLoop();
}
});
subscriberLoopThread.set(thread);
thread.start();
bayeuxClient.handshake();
}
/**
* Stops communication with the server. This method can be used
* to dispose of this instance resources. However, {@link #open()} can
* be used to start the communication again, and continue using the
* same list of subscriptions.
*/
@Override
public void close() {
subscriberLoopThread.get().interrupt();
bayeuxClient.disconnect();
}
private void subscriberLoop() {
try {
while (!Thread.currentThread().isInterrupted()) {
// It has been preferred to use Object.wait() instead of
// BayeuxClient.waitFor(), because wait is interruptible.
// This way this loop will be interrupted immediately on close.
synchronized (bayeuxClient) {
while (isConnected == bayeuxClient.isConnected())
bayeuxClient.wait();
isConnected = bayeuxClient.isConnected();
}
if (isConnected) {
onConnected();
}
}
} catch (InterruptedException e) {
LOG.debug("Subscriber thread stopped");
}
}
private void onConnected() {
LOG.debug("Resubscribing all subscriptions");
for (Subscription subscription : subscriptions) {
bayeuxClient.getChannel(subscription.channel).subscribe(subscription.bayeuxListener);
}
}
/**
* Subscribes to a channel for receiving events.
*
* @return An EventSubscription instance that can be used to unsubscribe.
*/
public EventSubscription subscribe(final String channel, final GenesysEventListener listener) {
final MessageListener bayeuxListener = new MessageListener() {
@Override public void onMessage(ClientSessionChannel channel, final Message message) {
LOG_EVENT.debug("Received event on channel: " + message.getChannel());
LOG_EVENT_CONTENT_RAW.debug("Content: " + message.getJSON());
if (LOG_EVENT_CONTENT_PRETTY.isDebugEnabled())
LOG_EVENT_CONTENT_PRETTY.debug("Content:\n" + JsonUtil.prettify(message.getJSON()));
eventExecutor.execute(new Runnable() {
@Override
public void run() {
try {
listener.eventReceived(new GenesysEvent(message));
} catch (Throwable t) {
LOG.error("Exception handling event", t);
throw t;
}
}
});
}
};
return new Subscription(channel, bayeuxListener);
}
/**
* Subscribes to all channels using <code>"/**"</code>.
*
* @see #subscribe(String, GenesysEventListener)
*/
public EventSubscription subscribeAll(GenesysEventListener listener) {
return subscribe("/**", listener);
}
private class Subscription implements EventSubscription {
private final String channel;
private final MessageListener bayeuxListener;
public Subscription(String channel, MessageListener bayeuxListener) {
this.channel = channel;
this.bayeuxListener = bayeuxListener;
subscriptions.add(this);
synchronized (bayeuxClient) {
if (isConnected) {
bayeuxClient.getChannel(channel).subscribe(bayeuxListener);
}
}
}
@Override
public void unsubscribe() {
subscriptions.remove(this);
synchronized (bayeuxClient) {
if (isConnected) {
bayeuxClient.getChannel(channel).unsubscribe(bayeuxListener);
}
}
}
}
private static void logRequest(HttpExchange exchange) {
String content;
try {
content = new String(exchange.getRequestContent().array(), "UTF-8");
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
LOG_EVENT_TRANSPORT.debug(
"Comet request to server: " + exchange.getAddress() +
", " + exchange.getMethod() + " " + exchange.getRequestURI() +
", content: " + content);
Jetty769Util.logHeaders(LOG_EVENT_TRANSPORT_HEADERS, exchange.getRequestFields());
}
/**
* <p>The {@link Setup} class is not thread-safe. Therefore always do the whole setup
* and creation of a {@link GenesysEventReceiver} in the same thread, or use according
* multi-threading techniques.
*/
public static class Setup {
private final GenesysClient client;
private final CookieSession cookieSession;
private final Authentication authentication;
private Executor eventExecutor;
private boolean webSocketEnabled;
protected Setup(GenesysClient client,
CookieSession session, Authentication authentication) {
this.client = client;
this.cookieSession = session;
this.authentication = authentication;
String webSocketProperty = System.getProperty("com.genesys.wsclient.websocket");
this.webSocketEnabled = webSocketProperty == null
? true
: Boolean.parseBoolean(webSocketProperty);
}
public GenesysEventReceiver create() {
if (eventExecutor == null) {
eventExecutor = client.asyncExecutor;
if (eventExecutor == null) {
throw new IllegalStateException("eventExecutor is mandatory");
}
}
return new GenesysEventReceiver(this);
}
/**
* (Mandatory if GenesysClient asyncExecutor not set) Executor for handling events received.
*/
public Setup eventExecutor(Executor eventExecutor) {
this.eventExecutor = eventExecutor;
return this;
}
/**
* (Optional) Disable the use of WebSocket.
*
* <p>By default, WebSocket is the preferred
* transport layer for events, and HTTP long-polling is used as a fall-back.
*
* <p>WebSocket can also be disabled by setting the system property
* <code>com.genesys.wsclient.websocket=false</code>.
*/
public Setup disableWebSocket() {
webSocketEnabled = false;
return this;
}
}
}