/* * Copyright 2014 Matthias Einwag * * The jawampa authors license this file to you under the Apache License, * version 2.0 (the "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at: * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations * under the License. */ package ws.wamp.jawampa; import io.netty.buffer.Unpooled; import io.netty.channel.Channel; import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelFutureListener; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.EventLoopGroup; import io.netty.channel.SimpleChannelInboundHandler; import io.netty.channel.nio.NioEventLoopGroup; import java.net.URI; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map.Entry; import java.util.Set; import java.util.concurrent.Future; import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; import rx.Observable; import rx.Observable.OnSubscribe; import rx.Observer; import rx.Scheduler; import rx.Subscriber; import rx.Subscription; import rx.exceptions.OnErrorThrowable; import rx.functions.Action0; import rx.functions.Action1; import rx.functions.Func1; import rx.schedulers.Schedulers; import rx.subjects.AsyncSubject; import rx.subjects.BehaviorSubject; import rx.subscriptions.CompositeSubscription; import rx.subscriptions.Subscriptions; import ws.wamp.jawampa.WampMessages.AbortMessage; import ws.wamp.jawampa.WampMessages.CallMessage; import ws.wamp.jawampa.WampMessages.ErrorMessage; import ws.wamp.jawampa.WampMessages.EventMessage; import ws.wamp.jawampa.WampMessages.GoodbyeMessage; import ws.wamp.jawampa.WampMessages.InvocationMessage; import ws.wamp.jawampa.WampMessages.PublishedMessage; import ws.wamp.jawampa.WampMessages.RegisterMessage; import ws.wamp.jawampa.WampMessages.RegisteredMessage; import ws.wamp.jawampa.WampMessages.ResultMessage; import ws.wamp.jawampa.WampMessages.SubscribeMessage; import ws.wamp.jawampa.WampMessages.SubscribedMessage; import ws.wamp.jawampa.WampMessages.UnregisterMessage; import ws.wamp.jawampa.WampMessages.UnregisteredMessage; import ws.wamp.jawampa.WampMessages.UnsubscribeMessage; import ws.wamp.jawampa.WampMessages.UnsubscribedMessage; import ws.wamp.jawampa.WampMessages.WampMessage; import ws.wamp.jawampa.WampMessages.WelcomeMessage; import ws.wamp.jawampa.internal.IdGenerator; import ws.wamp.jawampa.internal.IdValidator; import ws.wamp.jawampa.internal.Promise; import ws.wamp.jawampa.internal.UriValidator; import ws.wamp.jawampa.transport.WampChannelEvents; import ws.wamp.jawampa.transport.WampClientChannelFactory; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; /** * Provides the client-side functionality for WAMP.<br> * The {@link WampClient} allows to make remote procedudure calls, subscribe to * and publish events and to register functions for RPC.<br> * It has to be constructed through a {@link WampClientBuilder} and can not * directly be instantiated. */ public class WampClient { /** * Possible states for a WAMP session between client and router */ public static enum Status { /** The session is not connected */ Disconnected, /** The session is trying to connect to the router */ Connecting, /** The session is connected to the router */ Connected } /** The current status */ Status status = Status.Disconnected; BehaviorSubject<Status> statusObservable = BehaviorSubject.create(Status.Disconnected); final EventLoopGroup eventLoop; final Scheduler scheduler; final ObjectMapper objectMapper = new ObjectMapper(); final URI routerUri; final String realm; /** Returns the URI of the router to which this client is connected */ public URI routerUri() { return routerUri; } /** Returns the name of the realm on the router */ public String realm() { return realm; } final boolean closeClientOnErrors; boolean isCompleted = false; /** The factory which is used to create new transports to the remote peer */ final WampClientChannelFactory channelFactory; Channel channel; ChannelFuture connectFuture; SessionHandler handler; long lastRequestId = IdValidator.MIN_VALID_ID; final int totalNrReconnects; final int reconnectInterval; int remainingNrReconnects = 0; Subscription reconnectSubscription; long sessionId; ObjectNode welcomeDetails = null; final WampRoles[] clientRoles; WampRoles[] routerRoles; enum PubSubState { Subscribing, Subscribed, Unsubscribing, Unsubscribed } enum RegistrationState { Registering, Registered, Unregistering, Unregistered } static class RequestMapEntry { public final int requestType; public final AsyncSubject<?> resultSubject; public RequestMapEntry(int requestType, AsyncSubject<?> resultSubject) { this.requestType = requestType; this.resultSubject = resultSubject; } } static class SubscriptionMapEntry { public PubSubState state; public long subscriptionId = 0; public final List<Subscriber<? super PubSubData>> subscribers = new ArrayList<Subscriber<? super PubSubData>>(); public SubscriptionMapEntry(PubSubState state) { this.state = state; } } static class RegisteredProceduresMapEntry { public RegistrationState state; public long registrationId = 0; public final Subscriber<? super Request> subscriber; public RegisteredProceduresMapEntry(Subscriber<? super Request> subscriber, RegistrationState state) { this.subscriber = subscriber; this.state = state; } } HashMap<Long, RequestMapEntry> requestMap = new HashMap<Long, WampClient.RequestMapEntry>(); HashMap<String, SubscriptionMapEntry> subscriptionsByUri = new HashMap<String, SubscriptionMapEntry>(); HashMap<Long, SubscriptionMapEntry> subscriptionsBySubscriptionId = new HashMap<Long, SubscriptionMapEntry>(); HashMap<String, RegisteredProceduresMapEntry> registeredProceduresByUri = new HashMap<String, RegisteredProceduresMapEntry>(); HashMap<Long, RegisteredProceduresMapEntry> registeredProceduresById = new HashMap<Long, RegisteredProceduresMapEntry>(); WampClient(URI routerUri, String realm, WampRoles[] roles, boolean closeClientOnErrors, WampClientChannelFactory channelFactory, int nrReconnects, int reconnectInterval) { // Create an eventloop and the RX scheduler on top of it this.eventLoop = new NioEventLoopGroup(1, new ThreadFactory() { @Override public Thread newThread(Runnable r) { Thread t = new Thread(r, "WampClientEventLoop"); t.setDaemon(true); return t; } }); this.scheduler = Schedulers.from(eventLoop); this.routerUri = routerUri; this.realm = realm; this.clientRoles = roles; this.closeClientOnErrors = closeClientOnErrors; this.channelFactory = channelFactory; this.totalNrReconnects = nrReconnects; this.reconnectInterval = reconnectInterval; } private void completeStatus(Exception e) { if (isCompleted) return; isCompleted = true; if (e != null) statusObservable.onError(e); else statusObservable.onCompleted(); } /** * Opens the session<br> * This should be called after a subscription on {@link #statusChanged} * was installed.<br> * If the session was already opened this has no effect besides * resetting the reconnect counter.<br> * If the session was already closed through a call to {@link #close} * no new connect attempt will be performed. */ public void open() { eventLoop.execute(new Runnable() { @Override public void run() { if (isCompleted) return; // Reset the number of reconnects // This happens in both connecting and disconnected case remainingNrReconnects = totalNrReconnects; if (remainingNrReconnects > 0) remainingNrReconnects--; if (status == Status.Disconnected) { status = Status.Connecting; statusObservable.onNext(status); beginConnect(); } } }); } /** * Returns whether reconnects are allowed (true) or not (false) */ private boolean mayReconnect() { return remainingNrReconnects != 0; } /** * Schedules a reconnect operation<br> * The reconnect can be canceled by unsubscribing the {@link #reconnectSubscription} * */ private void scheduleReconnect() { // Check for possible reconnects if (remainingNrReconnects == 0) return; status = Status.Connecting; // Decrease remaining number of reconnects if it's not infinite if (remainingNrReconnects > 0) remainingNrReconnects--; // Make a composite subscription that is used to cancel the // reconnect. The status of it can be checked inside the callback final CompositeSubscription sub = new CompositeSubscription(); sub.add(scheduler.createWorker().schedule(new Action0() { @Override public void call() { if (reconnectSubscription.isUnsubscribed()) return; beginConnect(); } }, reconnectInterval, TimeUnit.MILLISECONDS)); reconnectSubscription = sub; // Store it as our new reconnect subscription } /** * Netty handler for that receives and processes WampMessages and state * events from the pipeline. * A new instance of this is created for each connection attempt. */ private class SessionHandler extends SimpleChannelInboundHandler<WampMessage> { @Override public void handlerAdded(ChannelHandlerContext ctx) throws Exception { // System.out.println("Session handler added"); } @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { if (handler != this) return; // System.out.println("Session handler active"); } @Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { if (handler != this) return; // System.out.println("Session handler inactive"); closeCurrentTransport(); // Can emit the status, because we always go from connected to closed here statusObservable.onNext(status); // Try reconnect if possible if (mayReconnect()) { scheduleReconnect(); statusObservable.onNext(status); } } @Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { if (handler != this) return; if (evt == WampChannelEvents.WEBSOCKET_CONN_ESTABLISHED) { // System.out.println("Session websocket connection established"); // Connection to the remote host was established // However the WAMP session is not established until the handshake was finished // Put the requested roles in the Hello message ObjectNode o = objectMapper.createObjectNode(); ObjectNode rolesNode = o.putObject("roles"); for (WampRoles role : clientRoles) { rolesNode.putObject(role.toString()); } ctx.writeAndFlush(new WampMessages.HelloMessage(realm, o)); } } @Override protected void channelRead0(ChannelHandlerContext ctx, WampMessage msg) throws Exception { if (handler != this) return; onMessageReceived(msg); } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { // System.out.println("Session handler caught exception " + cause); super.exceptionCaught(ctx, cause); } } /** * Starts an connection attempt to the router */ private void beginConnect() { handler = new SessionHandler(); try { connectFuture = channelFactory.createChannel(handler, eventLoop, objectMapper); connectFuture.addListener(new ChannelFutureListener() { @Override public void operationComplete(ChannelFuture f) throws Exception { if (f.isSuccess()) { if (f == connectFuture) { // Our new channel is connected channel = f.channel(); connectFuture = null; } else { // We we're connected but aren't interested in the channel anymore // Therefore we close the new channel f.channel().writeAndFlush(Unpooled.EMPTY_BUFFER) .addListener(ChannelFutureListener.CLOSE); } } else if (!f.isCancelled()) { // Remark: Might be called directly in addListener // Therefore addListener should be the last call in beginConnect closeCurrentTransport(); // Try reconnect if possible, otherwise announce close if (mayReconnect()) { scheduleReconnect(); } else { statusObservable.onNext(status); } } } }); } catch (Exception e) { // Catch exceptions that can happen during creating the channel // These are normally signs that something is wrong with our configuration // Therefore we don't trigger retries closeCurrentTransport(); statusObservable.onNext(status); completeStatus(e); } } private void closeCurrentTransport() { if (status == Status.Disconnected) return; if (channel != null) { channel.writeAndFlush(Unpooled.EMPTY_BUFFER) .addListener(ChannelFutureListener.CLOSE); channel = null; } // If we are in the connect process mark the connect future as cancelled if (connectFuture != null) connectFuture.cancel(false); connectFuture = null; handler = null; // Stop the reconnect timer. In case of real reconnects // it will be initialized after closeCurrentTransport() if (reconnectSubscription != null) { reconnectSubscription.unsubscribe(); reconnectSubscription = null; } welcomeDetails = null; sessionId = 0; status = Status.Disconnected; clearPendingRequests(new ApplicationError(ApplicationError.TRANSPORT_CLOSED)); clearAllSubscriptions(null); clearAllRegisteredProcedures(null); } /** * Closes the session.<br> * It will not be possible to open the session again with {@link #open} for safety * reasons. If a new session is required a new {@link WampClient} should be built * through the used {@link WampClientBuilder}. */ public void close() { // Avoid crashes on multiple/concurrent shutdowns if (eventLoop.isShuttingDown() || eventLoop.isShutdown()) return; eventLoop.execute(new Runnable() { @Override public void run() { if (!isCompleted) // Check if already closed { if (status == Status.Connected) { // Send goodbye to the remote GoodbyeMessage msg = new GoodbyeMessage(null, ApplicationError.SYSTEM_SHUTDOWN); channel.writeAndFlush(msg); } if (status != Status.Disconnected) { // Close the connection (or connection attempt) remainingNrReconnects = 0; // Don't reconnect closeCurrentTransport(); statusObservable.onNext(status); } // Normal close without an error completeStatus(null); } // Shut down the eventLoop if it didn't happen before if (eventLoop.isShuttingDown() || eventLoop.isShutdown()) return; eventLoop.shutdownGracefully(); } }); } /** * An Observable that allows to monitor the connection status of the Session. */ public Observable<Status> statusChanged() { return statusObservable; } private void onProtocolError() { onSessionError(new ApplicationError(ApplicationError.PROTCOL_ERROR)); } private void onSessionError(ApplicationError error) { // We move from connected to disconnected closeCurrentTransport(); statusObservable.onNext(status); if (closeClientOnErrors) { remainingNrReconnects = 0; completeStatus(error); } else { if (mayReconnect()) { scheduleReconnect(); statusObservable.onNext(status); } } } private void onMessageReceived(WampMessage msg) { if (welcomeDetails == null) { // We were not yet welcomed if (msg instanceof WelcomeMessage) { // Receive a welcome. Now the session is established! welcomeDetails = ((WelcomeMessage) msg).details; sessionId = ((WelcomeMessage) msg).sessionId; // Extract the roles of the remote side JsonNode roleNode = welcomeDetails.get("roles"); if (roleNode == null || !roleNode.isObject()) { onProtocolError(); return; } routerRoles = null; Set<WampRoles> rroles = new HashSet<WampRoles>(); Iterator<String> roleKeys = roleNode.fieldNames(); while (roleKeys.hasNext()) { WampRoles role = WampRoles.fromString(roleKeys.next()); if (role != null) rroles.add(role); } routerRoles = new WampRoles[rroles.size()]; int i = 0; for (WampRoles r : rroles) { routerRoles[i] = r; i++; } remainingNrReconnects = totalNrReconnects; status = Status.Connected; statusObservable.onNext(status); } else if (msg instanceof AbortMessage) { // The remote doesn't want us to connect :( AbortMessage abort = (AbortMessage) msg; onSessionError(new ApplicationError(abort.reason)); } } else { // We were already welcomed if (msg instanceof WelcomeMessage) { onProtocolError(); } else if (msg instanceof AbortMessage) { onProtocolError(); } else if (msg instanceof GoodbyeMessage) { // Reply the goodbye channel.writeAndFlush(new GoodbyeMessage(null, ApplicationError.GOODBYE_AND_OUT)); // We could also use the reason from the msg, but this would be harder // to determinate from a "real" error onSessionError(new ApplicationError(ApplicationError.GOODBYE_AND_OUT)); } else if (msg instanceof ResultMessage) { ResultMessage r = (ResultMessage)msg; RequestMapEntry requestInfo = requestMap.get(r.requestId); if (requestInfo == null) return; // Ignore the result if (requestInfo.requestType != WampMessages.CallMessage.ID) { onProtocolError(); return; } requestMap.remove(r.requestId); Reply reply = new Reply(r.arguments, r.argumentsKw); @SuppressWarnings("unchecked") AsyncSubject<Reply> subject = (AsyncSubject<Reply>)requestInfo.resultSubject; subject.onNext(reply); subject.onCompleted(); } else if (msg instanceof ErrorMessage) { ErrorMessage r = (ErrorMessage)msg; if (r.requestType == WampMessages.CallMessage.ID || r.requestType == WampMessages.SubscribeMessage.ID || r.requestType == WampMessages.UnsubscribeMessage.ID || r.requestType == WampMessages.PublishMessage.ID || r.requestType == WampMessages.RegisterMessage.ID || r.requestType == WampMessages.UnregisterMessage.ID) { RequestMapEntry requestInfo = requestMap.get(r.requestId); if (requestInfo == null) return; // Ignore the error // Check whether the request type we sent equals the // request type for the error we receive if (requestInfo.requestType != r.requestType) { onProtocolError(); return; } requestMap.remove(r.requestId); ApplicationError err = new ApplicationError(r.error, r.arguments, r.argumentsKw); requestInfo.resultSubject.onError(err); } } else if (msg instanceof SubscribedMessage) { SubscribedMessage m = (SubscribedMessage)msg; RequestMapEntry requestInfo = requestMap.get(m.requestId); if (requestInfo == null) return; // Ignore the result if (requestInfo.requestType != WampMessages.SubscribeMessage.ID) { onProtocolError(); return; } requestMap.remove(m.requestId); @SuppressWarnings("unchecked") AsyncSubject<Long> subject = (AsyncSubject<Long>)requestInfo.resultSubject; subject.onNext(m.subscriptionId); subject.onCompleted(); } else if (msg instanceof UnsubscribedMessage) { UnsubscribedMessage m = (UnsubscribedMessage)msg; RequestMapEntry requestInfo = requestMap.get(m.requestId); if (requestInfo == null) return; // Ignore the result if (requestInfo.requestType != WampMessages.UnsubscribeMessage.ID) { onProtocolError(); return; } requestMap.remove(m.requestId); @SuppressWarnings("unchecked") AsyncSubject<Void> subject = (AsyncSubject<Void>)requestInfo.resultSubject; subject.onNext(null); subject.onCompleted(); } else if (msg instanceof EventMessage) { EventMessage ev = (EventMessage)msg; SubscriptionMapEntry entry = subscriptionsBySubscriptionId.get(ev.subscriptionId); if (entry == null || entry.state != PubSubState.Subscribed) return; // Ignore the result PubSubData evResult = new PubSubData(ev.arguments, ev.argumentsKw); // publish the event for (Subscriber<? super PubSubData> s : entry.subscribers) { s.onNext(evResult); } } else if (msg instanceof PublishedMessage) { PublishedMessage m = (PublishedMessage)msg; RequestMapEntry requestInfo = requestMap.get(m.requestId); if (requestInfo == null) return; // Ignore the result if (requestInfo.requestType != WampMessages.PublishMessage.ID) { onProtocolError(); return; } requestMap.remove(m.requestId); @SuppressWarnings("unchecked") AsyncSubject<Long> subject = (AsyncSubject<Long>)requestInfo.resultSubject; subject.onNext(m.publicationId); subject.onCompleted(); } else if (msg instanceof RegisteredMessage) { RegisteredMessage m = (RegisteredMessage)msg; RequestMapEntry requestInfo = requestMap.get(m.requestId); if (requestInfo == null) return; // Ignore the result if (requestInfo.requestType != WampMessages.RegisterMessage.ID) { onProtocolError(); return; } requestMap.remove(m.requestId); @SuppressWarnings("unchecked") AsyncSubject<Long> subject = (AsyncSubject<Long>)requestInfo.resultSubject; subject.onNext(m.registrationId); subject.onCompleted(); } else if (msg instanceof UnregisteredMessage) { UnregisteredMessage m = (UnregisteredMessage)msg; RequestMapEntry requestInfo = requestMap.get(m.requestId); if (requestInfo == null) return; // Ignore the result if (requestInfo.requestType != WampMessages.UnregisterMessage.ID) { onProtocolError(); return; } requestMap.remove(m.requestId); @SuppressWarnings("unchecked") AsyncSubject<Void> subject = (AsyncSubject<Void>)requestInfo.resultSubject; subject.onNext(null); subject.onCompleted(); } else if (msg instanceof InvocationMessage) { InvocationMessage m = (InvocationMessage)msg; RegisteredProceduresMapEntry entry = registeredProceduresById.get(m.registrationId); if (entry == null || entry.state != RegistrationState.Registered) { // Send an error that we are no longer registered channel.writeAndFlush(new ErrorMessage(InvocationMessage.ID, m.requestId, null, ApplicationError.NO_SUCH_PROCEDURE, null, null)); } else { // Send the request to the subscriber, which can then send responses Request request = new Request(this, channel, m.requestId, m.arguments, m.argumentsKw); entry.subscriber.onNext(request); } } else { // Unknown message } } } /** * Builds an ArrayNode from all positional arguments in a WAMP message.<br> * If there are no positional arguments then null will be returned, as * WAMP requires no empty arguments list to be transmitted. * @param args All positional arguments * @return An ArrayNode containing positional arguments or null */ ArrayNode buildArgumentsArray(Object... args) { if (args.length == 0) return null; // Build the arguments array and serialize the arguments final ArrayNode argArray = objectMapper.createArrayNode(); for (Object arg : args) { argArray.addPOJO(arg); } return argArray; } private void clearPendingRequests(Throwable e) { for (Entry<Long, RequestMapEntry> entry : requestMap.entrySet()) { entry.getValue().resultSubject.onError(e); } requestMap.clear(); } private void clearAllSubscriptions(Throwable e) { for (Entry<String, SubscriptionMapEntry> entry : subscriptionsByUri.entrySet()) { for (Subscriber<? super PubSubData> s : entry.getValue().subscribers) { if (e == null) s.onCompleted(); else s.onError(e); } entry.getValue().state = PubSubState.Unsubscribed; } subscriptionsByUri.clear(); subscriptionsBySubscriptionId.clear(); } private void clearAllRegisteredProcedures(Throwable e) { for (Entry<String, RegisteredProceduresMapEntry> entry : registeredProceduresByUri.entrySet()) { if (e == null) entry.getValue().subscriber.onCompleted(); else entry.getValue().subscriber.onError(e); entry.getValue().state = RegistrationState.Unregistered; } registeredProceduresByUri.clear(); registeredProceduresById.clear(); } /** * Publishes an event under the given topic. * @param topic The topic that should be used for publishing the event * @param args A list of all positional arguments of the event to publish. * These will be get serialized according to the Jackson library serializing * behavior. * @return An observable that provides a notification whether the event * publication was successful. This contains either a single value (the * publication ID) and will then be completed or will be completed with * an error if the event could not be published. */ public Observable<Long> publish(final String topic, Object... args) { return publish(topic, buildArgumentsArray(args), null); } /** * Publishes an event under the given topic. * @param topic The topic that should be used for publishing the event * @param event The event to publish * @return An observable that provides a notification whether the event * publication was successful. This contains either a single value (the * publication ID) and will then be completed or will be completed with * an error if the event could not be published. */ public Observable<Long> publish(final String topic, PubSubData event) { if (event != null) return publish(topic, event.arguments, event.keywordArguments); else return publish(topic, null, null); } /** * Publishes an event under the given topic. * @param topic The topic that should be used for publishing the event * @param arguments The positional arguments for the published event * @param argumentsKw The keyword arguments for the published event. * These will only be taken into consideration if arguments is not null. * @return An observable that provides a notification whether the event * publication was successful. This contains either a single value (the * publication ID) and will then be completed or will be completed with * an error if the event could not be published. */ public Observable<Long> publish(final String topic, final ArrayNode arguments, final ObjectNode argumentsKw) { final AsyncSubject<Long> resultSubject = AsyncSubject.create(); try { UriValidator.validate(topic); } catch (WampError e) { resultSubject.onError(e); return resultSubject; } eventLoop.execute(new Runnable() { @Override public void run() { if (status != Status.Connected) { resultSubject.onError(new ApplicationError(ApplicationError.NOT_CONNECTED)); return; } final long requestId = IdGenerator.newLinearId(lastRequestId, requestMap); lastRequestId = requestId; final WampMessages.PublishMessage msg = new WampMessages.PublishMessage(requestId, null, topic, arguments, argumentsKw); requestMap.put(requestId, new RequestMapEntry(WampMessages.PublishMessage.ID, resultSubject)); channel.writeAndFlush(msg); } }); return resultSubject; } /** * Registers a procedure at the router which will afterwards be available * for remote procedure calls from other clients.<br> * The actual registration will only happen after the user subscribes on * the returned Observable. This guarantees that no RPC requests get lost. * Incoming RPC requests will be pushed to the Subscriber via it's * onNext method. The Subscriber can send responses through the methods on * the {@link Request}.<br> * If the client no longer wants to provide the method it can call * unsubscribe() on the Subscription to unregister the procedure.<br> * If the connection closes onCompleted will be called.<br> * In case of errors during subscription onError will be called. * @param topic The name of the procedure which this client wants to * provide.<br> * Must be valid WAMP URI. * @return An observable that can be used to provide a procedure. */ public Observable<Request> registerProcedure(final String topic) { return Observable.create(new OnSubscribe<Request>() { @Override public void call(final Subscriber<? super Request> subscriber) { try { UriValidator.validate(topic); } catch (WampError e) { subscriber.onError(e); return; } eventLoop.execute(new Runnable() { @Override public void run() { // If the Subscriber unsubscribed in the meantime we return early if (subscriber.isUnsubscribed()) return; // Set subscription to completed if we are not connected if (status != Status.Connected) { subscriber.onCompleted(); return; } final RegisteredProceduresMapEntry entry = registeredProceduresByUri.get(topic); // Check if we have already registered a function with the same name if (entry != null) { subscriber.onError( new ApplicationError(ApplicationError.PROCEDURE_ALREADY_EXISTS)); return; } // Insert a new entry in the subscription map final RegisteredProceduresMapEntry newEntry = new RegisteredProceduresMapEntry(subscriber, RegistrationState.Registering); registeredProceduresByUri.put(topic, newEntry); // Make the subscribe call final long requestId = IdGenerator.newLinearId(lastRequestId, requestMap); lastRequestId = requestId; final RegisterMessage msg = new RegisterMessage(requestId, null, topic); final AsyncSubject<Long> registerFuture = AsyncSubject.create(); registerFuture .observeOn(WampClient.this.scheduler) .subscribe(new Action1<Long>() { @Override public void call(Long t1) { // Check if we were unsubscribed (through transport close) if (newEntry.state != RegistrationState.Registering) return; // Registration at the broker was successful newEntry.state = RegistrationState.Registered; newEntry.registrationId = t1; registeredProceduresById.put(t1, newEntry); // Add the cancellation functionality to the subscriber attachCancelRegistrationAction(subscriber, newEntry, topic); } }, new Action1<Throwable>() { @Override public void call(Throwable t1) { // Error on registering if (newEntry.state != RegistrationState.Registering) return; // Remark: Actually noone can't unregister until this Future completes because // the unregister functionality is only added in the success case // However a transport close event could set us to Unregistered early newEntry.state = RegistrationState.Unregistered; boolean isClosed = false; if (t1 instanceof ApplicationError && ((ApplicationError)t1).uri.equals(ApplicationError.TRANSPORT_CLOSED)) isClosed = true; if (isClosed) subscriber.onCompleted(); else subscriber.onError(t1); registeredProceduresByUri.remove(topic); } }); requestMap.put(requestId, new RequestMapEntry(RegisterMessage.ID, registerFuture)); channel.writeAndFlush(msg); } }); } }); } /** * Add an action that is added to the subscriber which is executed * if unsubscribe is called on a registered procedure.<br> * This action will lead to unregistering a provided function at the dealer. */ private void attachCancelRegistrationAction(final Subscriber<? super Request> subscriber, final RegisteredProceduresMapEntry mapEntry, final String topic) { subscriber.add(Subscriptions.create(new Action0() { @Override public void call() { eventLoop.execute(new Runnable() { @Override public void run() { if (mapEntry.state != RegistrationState.Registered) return; mapEntry.state = RegistrationState.Unregistering; registeredProceduresByUri.remove(topic); registeredProceduresById.remove(mapEntry.registrationId); // Make the unregister call final long requestId = IdGenerator.newLinearId(lastRequestId, requestMap); lastRequestId = requestId; final UnregisterMessage msg = new UnregisterMessage(requestId, mapEntry.registrationId); final AsyncSubject<Void> unregisterFuture = AsyncSubject.create(); unregisterFuture .observeOn(WampClient.this.scheduler) .subscribe(new Action1<Void>() { @Override public void call(Void t1) { // Unregistration at the broker was successful mapEntry.state = RegistrationState.Unregistered; } }, new Action1<Throwable>() { @Override public void call(Throwable t1) { // Error on unregister } }); requestMap.put(requestId, new RequestMapEntry( UnregisterMessage.ID, unregisterFuture)); channel.writeAndFlush(msg); } }); } })); } /** * Returns an observable that allows to subscribe on the given topic.<br> * The actual subscription will only be made after subscribe() was called * on it.<br> * This version of makeSubscription will automatically transform the * received events data into the type eventClass and will therefore return * a mapped Observable. It will only look at and transform the first * argument of the received events arguments, therefore it can only be used * for events that carry either a single or no argument.<br> * Received publications will be pushed to the Subscriber via it's * onNext method.<br> * The client can unsubscribe from the topic by calling unsubscribe() on * it's Subscription.<br> * If the connection closes onCompleted will be called.<br> * In case of errors during subscription onError will be called. * @param topic The topic to subscribe on.<br> * Must be valid WAMP URI. * @param eventClass The class type into which the received event argument * should be transformed. E.g. use String.class to let the client try to * transform the first argument into a String and let the return value of * of the call be Observable<String>. * @return An observable that can be used to subscribe on the topic. */ public <T> Observable<T> makeSubscription(final String topic, final Class<T> eventClass) { return makeSubscription(topic).map(new Func1<PubSubData,T>() { @Override public T call(PubSubData ev) { if (eventClass == null || eventClass == Void.class) { // We don't need a value return null; } if (ev.arguments == null || ev.arguments.size() < 1) throw OnErrorThrowable.from(new ApplicationError(ApplicationError.MISSING_VALUE)); JsonNode eventNode = ev.arguments.get(0); if (eventNode.isNull()) return null; T eventValue; try { eventValue = objectMapper.convertValue(eventNode, eventClass); } catch (IllegalArgumentException e) { throw OnErrorThrowable.from(new ApplicationError(ApplicationError.INVALID_VALUE_TYPE)); } return eventValue; } }); } /** * Returns an observable that allows to subscribe on the given topic.<br> * The actual subscription will only be made after subscribe() was called * on it.<br> * Received publications will be pushed to the Subscriber via it's * onNext method.<br> * The client can unsubscribe from the topic by calling unsubscribe() on * it's Subscription.<br> * If the connection closes onCompleted will be called.<br> * In case of errors during subscription onError will be called. * @param topic The topic to subscribe on.<br> * Must be valid WAMP URI. * @return An observable that can be used to subscribe on the topic. */ public Observable<PubSubData> makeSubscription(final String topic) { return Observable.create(new OnSubscribe<PubSubData>() { @Override public void call(final Subscriber<? super PubSubData> subscriber) { try { UriValidator.validate(topic); } catch (WampError e) { subscriber.onError(e); return; } eventLoop.execute(new Runnable() { @Override public void run() { // If the Subscriber unsubscribed in the meantime we return early if (subscriber.isUnsubscribed()) return; // Set subscription to completed if we are not connected if (status != Status.Connected) { subscriber.onCompleted(); return; } final SubscriptionMapEntry entry = subscriptionsByUri.get(topic); if (entry != null) { // We are already subscribed at the dealer entry.subscribers.add(subscriber); if (entry.state == PubSubState.Subscribed) { // Add the cancellation functionality only if we are // already subscribed. If not then this will be added // once subscription is completed attachPubSubCancelationAction(subscriber, entry, topic); } } else { // need to subscribe // Insert a new entry in the subscription map final SubscriptionMapEntry newEntry = new SubscriptionMapEntry(PubSubState.Subscribing); newEntry.subscribers.add(subscriber); subscriptionsByUri.put(topic, newEntry); // Make the subscribe call final long requestId = IdGenerator.newLinearId(lastRequestId, requestMap); lastRequestId = requestId; final SubscribeMessage msg = new SubscribeMessage(requestId, null, topic); final AsyncSubject<Long> subscribeFuture = AsyncSubject.create(); subscribeFuture .observeOn(WampClient.this.scheduler) .subscribe(new Action1<Long>() { @Override public void call(Long t1) { // Check if we were unsubscribed (through transport close) if (newEntry.state != PubSubState.Subscribing) return; // Subscription at the broker was successful newEntry.state = PubSubState.Subscribed; newEntry.subscriptionId = t1; subscriptionsBySubscriptionId.put(t1, newEntry); // Add the cancellation functionality to all subscribers // If one is already unsubscribed this will immediately call // the cancellation function for this subscriber for (Subscriber<? super PubSubData> s : newEntry.subscribers) { attachPubSubCancelationAction(s, newEntry, topic); } } }, new Action1<Throwable>() { @Override public void call(Throwable t1) { // Error on subscription if (newEntry.state != PubSubState.Subscribing) return; // Remark: Actually noone can't unsubscribe until this Future completes because // the unsubscription functionality is only added in the success case // However a transport close event could set us to Unsubscribed early newEntry.state = PubSubState.Unsubscribed; boolean isClosed = false; if (t1 instanceof ApplicationError && ((ApplicationError)t1).uri.equals(ApplicationError.TRANSPORT_CLOSED)) isClosed = true; for (Subscriber<? super PubSubData> s : newEntry.subscribers) { if (isClosed) s.onCompleted(); else s.onError(t1); } newEntry.subscribers.clear(); subscriptionsByUri.remove(topic); } }); requestMap.put(requestId, new RequestMapEntry(SubscribeMessage.ID, subscribeFuture)); channel.writeAndFlush(msg); } } }); } }); } /** * Add an action that is added to the subscriber which is executed * if unsubscribe is called. This action will lead to the unsubscription at the * broker once the topic subscription at the broker is no longer used by anyone. */ private void attachPubSubCancelationAction(final Subscriber<? super PubSubData> subscriber, final SubscriptionMapEntry mapEntry, final String topic) { subscriber.add(Subscriptions.create(new Action0() { @Override public void call() { eventLoop.execute(new Runnable() { @Override public void run() { mapEntry.subscribers.remove(subscriber); if (mapEntry.state == PubSubState.Subscribed && mapEntry.subscribers.size() == 0) { // We removed the last subscriber and can therefore unsubscribe from the dealer mapEntry.state = PubSubState.Unsubscribing; subscriptionsByUri.remove(topic); subscriptionsBySubscriptionId.remove(mapEntry.subscriptionId); // Make the unsubscribe call final long requestId = IdGenerator.newLinearId(lastRequestId, requestMap); lastRequestId = requestId; final UnsubscribeMessage msg = new UnsubscribeMessage(requestId, mapEntry.subscriptionId); final AsyncSubject<Void> unsubscribeFuture = AsyncSubject.create(); unsubscribeFuture .observeOn(WampClient.this.scheduler) .subscribe(new Action1<Void>() { @Override public void call(Void t1) { // Unsubscription at the broker was successful mapEntry.state = PubSubState.Unsubscribed; } }, new Action1<Throwable>() { @Override public void call(Throwable t1) { // Error on unsubscription } }); requestMap.put(requestId, new RequestMapEntry( UnsubscribeMessage.ID, unsubscribeFuture)); channel.writeAndFlush(msg); } } }); } })); } /** * Performs a remote procedure call through the router.<br> * The function will return immediately, as the actual call will happen * asynchronously. * @param procedure The name of the procedure to call. Must be a valid WAMP * Uri. * @param arguments A list of all positional arguments for the procedure call * @param argumentsKw All named arguments for the procedure call * @return An observable that provides a notification whether the call was * was successful and the return value. If the call is successful the * returned observable will be completed with a single value (the return value). * If the remote procedure call yields an error the observable will be completed * with an error. */ public Observable<Reply> call(final String procedure, final ArrayNode arguments, final ObjectNode argumentsKw) { final AsyncSubject<Reply> resultSubject = AsyncSubject.create(); try { UriValidator.validate(procedure); } catch (WampError e) { resultSubject.onError(e); return resultSubject; } eventLoop.execute(new Runnable() { @Override public void run() { if (status != Status.Connected) { resultSubject.onError(new ApplicationError(ApplicationError.NOT_CONNECTED)); return; } final long requestId = IdGenerator.newLinearId(lastRequestId, requestMap); lastRequestId = requestId; final CallMessage callMsg = new CallMessage(requestId, null, procedure, arguments, argumentsKw); requestMap.put(requestId, new RequestMapEntry(CallMessage.ID, resultSubject)); channel.writeAndFlush(callMsg); } }); return resultSubject; } /** * Performs a remote procedure call through the router.<br> * The function will return immediately, as the actual call will happen * asynchronously. * @param procedure The name of the procedure to call. Must be a valid WAMP * Uri. * @param args The list of positional arguments for the remote procedure call. * These will be get serialized according to the Jackson library serializing * behavior. * @return An observable that provides a notification whether the call was * was successful and the return value. If the call is successful the * returned observable will be completed with a single value (the return value). * If the remote procedure call yields an error the observable will be completed * with an error. */ public Observable<Reply> call(final String procedure, Object... args) { // Build the arguments array and serialize the arguments return call(procedure, buildArgumentsArray(args), null); } /** * Performs a remote procedure call through the router.<br> * The function will return immediately, as the actual call will happen * asynchronously.<br> * This overload of the call function will automatically map the received * reply value into the specified Java type by using Jacksons object mapping * facilities.<br> * Only the first value in the array of positional arguments will be taken * into account for the transformation. If multiple return values are required * another overload of this function has to be used.<br> * If the expected return type is not {@link Void} but the return value array * contains no value or if the value in the array can not be deserialized into * the expected type the returned {@link Observable} will be completed with * an error. * @param procedure The name of the procedure to call. Must be a valid WAMP * Uri. * @param returnValueClass The class of the expected return value. If the function * uses no return values Void should be used. * @param args The list of positional arguments for the remote procedure call. * These will be get serialized according to the Jackson library serializing * behavior. * @return An observable that provides a notification whether the call was * was successful and the return value. If the call is successful the * returned observable will be completed with a single value (the return value). * If the remote procedure call yields an error the observable will be completed * with an error. */ public <T> Observable<T> call(final String procedure, final Class<T> returnValueClass, Object... args) { return call(procedure, buildArgumentsArray(args), null).map(new Func1<Reply,T>() { @Override public T call(Reply reply) { if (returnValueClass == null || returnValueClass == Void.class) { // We don't need a return value return null; } if (reply.arguments == null || reply.arguments.size() < 1) throw OnErrorThrowable.from(new ApplicationError(ApplicationError.MISSING_RESULT)); JsonNode resultNode = reply.arguments.get(0); if (resultNode.isNull()) return null; T result; try { result = objectMapper.convertValue(resultNode, returnValueClass); } catch (IllegalArgumentException e) { // The returned exception is an aggregate one. That's not too nice :( throw OnErrorThrowable.from(new ApplicationError(ApplicationError.INVALID_VALUE_TYPE)); } return result; } }); } /** * Returns a future that will be completed once the client terminates.<br> * This can be used to wait for completion after {@link close close()} was called. */ public Future<Void> getTerminationFuture() { final Promise<Void> p = new Promise<Void>(); statusObservable.subscribe(new Observer<Status>() { @Override public void onCompleted() { p.resolve(null); } @Override public void onError(Throwable e) { p.resolve(null); } @Override public void onNext(Status t) { } }); return p.getFuture(); } }