/**
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses 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 io.hawtjms.provider.stomp;
import static io.hawtjms.provider.stomp.StompConstants.ACCEPT_VERSION;
import static io.hawtjms.provider.stomp.StompConstants.CLIENT_ID;
import static io.hawtjms.provider.stomp.StompConstants.CONNECTED;
import static io.hawtjms.provider.stomp.StompConstants.ERROR;
import static io.hawtjms.provider.stomp.StompConstants.HEARTBEAT;
import static io.hawtjms.provider.stomp.StompConstants.HOST;
import static io.hawtjms.provider.stomp.StompConstants.INVALID_CLIENTID_EXCEPTION;
import static io.hawtjms.provider.stomp.StompConstants.INVALID_SELECTOR_EXCEPTION;
import static io.hawtjms.provider.stomp.StompConstants.JMS_SECURITY_EXCEPTION;
import static io.hawtjms.provider.stomp.StompConstants.LOGIN;
import static io.hawtjms.provider.stomp.StompConstants.MESSAGE;
import static io.hawtjms.provider.stomp.StompConstants.PASSCODE;
import static io.hawtjms.provider.stomp.StompConstants.RECEIPT;
import static io.hawtjms.provider.stomp.StompConstants.RECEIPT_ID;
import static io.hawtjms.provider.stomp.StompConstants.RECEIPT_REQUESTED;
import static io.hawtjms.provider.stomp.StompConstants.SECURITY_EXCEPTION;
import static io.hawtjms.provider.stomp.StompConstants.SERVER;
import static io.hawtjms.provider.stomp.StompConstants.SESSION;
import static io.hawtjms.provider.stomp.StompConstants.STOMP;
import static io.hawtjms.provider.stomp.StompConstants.SUBSCRIPTION;
import static io.hawtjms.provider.stomp.StompConstants.VERSION;
import io.hawtjms.jms.meta.JmsConnectionId;
import io.hawtjms.jms.meta.JmsConnectionInfo;
import io.hawtjms.jms.meta.JmsConsumerId;
import io.hawtjms.jms.meta.JmsProducerId;
import io.hawtjms.jms.meta.JmsSessionId;
import io.hawtjms.jms.meta.JmsSessionInfo;
import io.hawtjms.provider.AsyncResult;
import io.hawtjms.provider.stomp.adapters.GenericStompServerAdaptor;
import io.hawtjms.provider.stomp.adapters.StompServerAdapter;
import io.hawtjms.provider.stomp.adapters.StompServerAdapterFactory;
import io.hawtjms.provider.stomp.message.StompJmsMessageFactory;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import javax.jms.InvalidClientIDException;
import javax.jms.InvalidSelectorException;
import javax.jms.JMSException;
import javax.jms.JMSSecurityException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The STOMP Connection instance, all resources reside under this class.
*/
public class StompConnection {
private static final Logger LOG = LoggerFactory.getLogger(StompConnection.class);
/**
* We currently restrict accepted versions to v1.1 and v1.2 which are nearly identical
* and can map quite easily to JMS. We could later add support for v1.0 but it doesn't
* lend itself to JMS mapping as easily so for now we don't support it.
*/
private static final String DEFAULT_ACCEPT_VERSIONS = "1.1,1.2";
private final StompJmsMessageFactory messageFactory;
private final Map<JmsSessionId, StompSession> sessions = new HashMap<JmsSessionId, StompSession>();
private final Map<String, AsyncResult<Void>> requests = new HashMap<String, AsyncResult<Void>>();
private final JmsConnectionInfo connectionInfo;
private final StompProvider provider;
private StompServerAdapter serverAdapter;
private AsyncResult<Void> pendingConnect;
private boolean connected;
private long requestCounter;
private String remoteSessionId;
private String version;
private String remoteServerId;
private String queuePrefix;
private String topicPrefix;
private String tempQueuePrefix;
private String tempTopicPrefix;
/**
* Create a new instance of the StompConnection.
*
* @param connectionInfo
* The connection information used to create this StompConnection.
*/
public StompConnection(StompProvider provider, JmsConnectionInfo connectionInfo) {
this.connectionInfo = connectionInfo;
this.provider = provider;
this.messageFactory = new StompJmsMessageFactory(this);
this.tempQueuePrefix = connectionInfo.getTempQueuePrefix();
this.tempTopicPrefix = connectionInfo.getTempTopicPrefix();
this.queuePrefix = connectionInfo.getQueuePrefix();
this.topicPrefix = connectionInfo.getTopicPrefix();
connectionInfo.getConnectionId().setProviderHint(this);
}
/**
* Initiates a Connection by generating a new StompFrame that contains all
* the properties which define the configured Connection. The StompConnection
* is not considered connected until it has received a CONNECTED frame back
* indicating that it's connection request was accepted.
*
* @param request
* the async request object that awaits the connection complete step.
*
* @throws IOException if a connection attempt is in-progress or already connected.
*/
public void connect(AsyncResult<Void> request) throws IOException {
if (connected || pendingConnect != null) {
throw new IOException("The connection is already established");
}
this.pendingConnect = request;
StompFrame connect = new StompFrame(STOMP);
connect.setProperty(ACCEPT_VERSION, DEFAULT_ACCEPT_VERSIONS);
if (connectionInfo.getUsername() != null && !connectionInfo.getUsername().isEmpty()) {
connect.setProperty(LOGIN, connectionInfo.getUsername());
}
if (connectionInfo.getPassword() != null && !connectionInfo.getPassword().isEmpty()) {
connect.setProperty(PASSCODE, connectionInfo.getPassword());
}
if (!connectionInfo.isOmitHost()) {
connect.setProperty(HOST, provider.getRemoteURI().getHost());
}
connect.setProperty(CLIENT_ID, connectionInfo.getClientId());
connect.setProperty(HEARTBEAT, "0,0"); // TODO - Implement Heart Beat support.
provider.send(connect);
}
/**
* Creates a new logical session instance. STOMP really doesn't support multiple
* sessions so our session is just a logical container for the JMS resources to
* reside in.
*
* @param sessionInfo
* the session information used to create the session instance.
* @param request
* the asynchronous request that is waiting for this action to complete.
*
* @throws IOException if a session with the same Id value already exists.
*/
public void createSession(JmsSessionInfo sessionInfo, AsyncResult<Void> request) throws IOException {
if (this.sessions.containsKey(sessionInfo.getSessionId())) {
throw new IOException("A Session with the given ID already exists.");
}
StompSession session = new StompSession(this, sessionInfo);
sessions.put(sessionInfo.getSessionId(), session);
request.onSuccess();
}
/**
* Handle all newly received StompFrames and update Connection resources with the
* new data.
*
* @param frame
* a newly received StompFrame.
*
* @throws JMSException if a JMS related error occurs.
* @throws IOException if an error occurs while handling the new StompFrame.
*/
public void processFrame(StompFrame frame) throws JMSException, IOException {
if (pendingConnect != null) {
processConnect(frame);
return;
}
if (frame.getCommand().equals(MESSAGE)) {
processMessage(frame);
} else if (frame.getCommand().equals(RECEIPT)) {
processReceipt(frame);
} else if (frame.getCommand().equals(ERROR)) {
processError(frame);
}
}
private void processConnect(StompFrame frame) throws JMSException, IOException {
if (pendingConnect != null && frame.getCommand().equals(ERROR)) {
LOG.info("Connection attempt failed: {}", frame.getErrorMessage());
pendingConnect.onFailure(exceptionFromErrorFrame(frame));
}
if (frame.getCommand().equals(CONNECTED)) {
if (this.pendingConnect == null) {
provider.fireProviderException(new IOException("Unexpected CONNECTED frame received."));
}
LOG.debug("Received new CONNCETED frame from Broker: {}", frame);
String sessionId = frame.getProperty(SESSION);
if (sessionId != null) {
LOG.debug("Broker assigned this connection an id: {}", sessionId);
remoteSessionId = sessionId;
}
String server = frame.getProperty(SERVER);
if (server != null) {
serverAdapter = StompServerAdapterFactory.create(this, server);
} else {
serverAdapter = new GenericStompServerAdaptor(this);
}
version = frame.getProperty(VERSION);
if (version == null || !DEFAULT_ACCEPT_VERSIONS.contains(version)) {
throw new IOException("Cannot connect to remote peer, version not supported: " + version);
}
LOG.info("Using STOMP server adapter: {}", serverAdapter.getServerName());
connected = true;
pendingConnect.onSuccess();
pendingConnect = null;
} else {
throw new IOException("Received Unexpected frame during connect: " + frame.getCommand());
}
}
/**
* Handles an incoming MESSAGE frame. The Frame is inspected for it's ID header and that
* value is turned into a JmsConsumerId which should allow us to locate the consumer that
* is subscribed for the destination the message was sent to.
*
* @param message
* the incoming message frame.
*
* @throws JMSException if an error occurs while dispatching the incoming message.
*/
protected void processMessage(StompFrame message) throws JMSException {
String id = message.getProperty(SUBSCRIPTION);
if (id == null) {
provider.fireProviderException(new IOException("Invalid Message frame received, no ID."));
}
JmsConsumerId consumerId = new JmsConsumerId(id);
StompConsumer consumer = getConsumer(consumerId);
if (consumer != null) {
consumer.processMessage(message);
} else {
LOG.debug("Received message for a consumer that doesn't exist.");
}
}
/**
* Handle incoming RECEIPT frames from the broker by triggering the onSuccess callback
* for the associated AsyncResult instance awaiting an answer.
*
* @param receiptFrame
* the receipt frame to process.
*/
protected void processReceipt(StompFrame receiptFrame) {
String receipt = receiptFrame.getProperty(RECEIPT_ID);
if (receipt == null || receipt.isEmpty()) {
provider.fireProviderException(new IOException("Invalid Receipt frame received."));
}
AsyncResult<Void> request = requests.remove(receipt);
if (request == null) {
LOG.warn("received receipt for unknown request: " + receipt);
}
request.onSuccess();
}
/**
* Handle STOMP ERROR frames by first checking for a pending request that
* matches the optional receipt Id in the ERROR frame and if that doesn't
* happen then fire an exception to the provider listener.
*
* @param errorFrame
* the ERROR frame to process.
*/
protected void processError(StompFrame errorFrame) {
JMSException error = exceptionFromErrorFrame(errorFrame);
String receipt = errorFrame.getProperty(RECEIPT_ID);
if (receipt != null && !receipt.isEmpty()) {
AsyncResult<Void> request = requests.remove(receipt);
if (request != null) {
request.onFailure(error);
return;
}
}
provider.fireProviderException(error);
}
/**
* @return the unique Connection Id for this STOMP Connection.
*/
public JmsConnectionId getConnectionId() {
return this.connectionInfo.getConnectionId();
}
/**
* Search for the StompSession with the given Id in this Connection's open
* sessions.
*
* @param sessionId
* the Id of the StompSession to lookup.
*
* @return the session with the given Id or null if no such session exists.
*/
public StompSession getSession(JmsSessionId sessionId) {
return this.sessions.get(sessionId);
}
/**
* Search for the StompProducer with the given Id in this Connection's open
* resources.
*
* @param producerId
* the Id of the StompProducer to lookup.
*
* @return the producer with the given Id or null if no such producer exists.
*/
public StompProducer getProducer(JmsProducerId producerId) {
StompProducer producer = null;
if (producerId.getProviderHint() instanceof StompProducer) {
producer = (StompProducer) producerId.getProviderHint();
} else {
StompSession session = getSession(producerId.getParentId());
producer = session.getProducer(producerId);
}
return producer;
}
/**
* Search for the StompConsumer with the given Id in this Connection's open
* resources.
*
* @param consumerId
* the Id of the StompConsumer to lookup.
*
* @return the consumer with the given Id or null if no such consumer exists.
*/
public StompConsumer getConsumer(JmsConsumerId consumerId) {
StompConsumer consumer = null;
if (consumerId.getProviderHint() instanceof StompConsumer) {
consumer = (StompConsumer) consumerId.getProviderHint();
} else {
StompSession session = getSession(consumerId.getParentId());
consumer = session.getConsumer(consumerId);
}
return consumer;
}
/**
* Sends the given STOMP frame without adding any additional properties or requesting
* a RECEIPT message for the frame.
*
* @param frame
* the frame to send.
*
* @throws IOException if an error occurs while sending the frame.
*/
public void send(StompFrame frame) throws IOException {
provider.send(frame);
}
/**
* Sends a StompFrame with supplied receipt Id which will result in the server
* sending a RECEIPT frame back once the request has been successfully processed,
* or an ERROR frame with the receipt Id set as a header if the request fails.
*
* @param frame
* the frame to send as a request.
* @param request
* the AsyncResult to signal once the request operation is completed.
*
* @throws IOException if an error occurs while sending the request frame.
*/
public void request(StompFrame frame, AsyncResult<Void> request) throws IOException {
String receiptId = String.valueOf(getNextRequestId());
frame.setProperty(RECEIPT_REQUESTED, receiptId);
requests.put(receiptId, request);
provider.send(frame);
}
/**
* AsnycResult class used to handle STOMP RECEIPT frames which complete
* some STOMP operation. Other STOMP classes can extend this to implement
* custom logic on completion of the asynchronous operation they initiated.
*
* @param <T>
*/
protected class ReceiptHandler implements AsyncResult<Void> {
private final AsyncResult<Void> pending;
public ReceiptHandler(AsyncResult<Void> pending) {
this.pending = pending;
}
@Override
public boolean isComplete() {
return pending.isComplete();
}
@Override
public void onFailure(Throwable result) {
pending.onFailure(result);
}
@Override
public void onSuccess(Void result) {
pending.onSuccess(result);
}
@Override
public void onSuccess() {
onSuccess(null);
}
}
//----------- Property Getters and Setters -------------------------------//
/**
* @return true if this StompConnection is fully connected.
*/
public boolean isConnected() {
return connected;
}
/**
* @return the remote session Id that the remote peer assigned this connection.
*/
public String getRemoteSessionId() {
return this.remoteSessionId;
}
/**
* @return the version string returned by the remote peer.
*/
public String getVersion() {
return this.version;
}
/**
* @return the server string returned by the remote peer.
*/
public String getRemoteServerId() {
return this.remoteServerId;
}
public String getUsername() {
return connectionInfo.getUsername();
}
public String getPassword() {
return connectionInfo.getPassword();
}
public StompProvider getProvider() {
return this.provider;
}
public String getQueuePrefix() {
return queuePrefix;
}
public void setQueuePrefix(String queuePrefix) {
this.queuePrefix = queuePrefix;
}
public String getTopicPrefix() {
return topicPrefix;
}
public void setTopicPrefix(String topicPrefix) {
this.topicPrefix = topicPrefix;
}
public String getTempQueuePrefix() {
return tempQueuePrefix;
}
public void setTempQueuePrefix(String tempQueuePrefix) {
this.tempQueuePrefix = tempQueuePrefix;
}
public String getTempTopicPrefix() {
return tempTopicPrefix;
}
public void setTempTopicPrefix(String tempTopicPrefix) {
this.tempTopicPrefix = tempTopicPrefix;
}
public StompServerAdapter getServerAdapter() {
return this.serverAdapter;
}
//---------- Internal utilities ------------------------------------------//
protected long getNextRequestId() {
return requestCounter++;
}
protected void checkConnected() throws IOException {
if (!connected) {
throw new IOException("already connected");
}
}
protected JMSException exceptionFromErrorFrame(StompFrame frame) {
JMSException exception = null;
String errorDetail = "";
try {
errorDetail = frame.getContentAsString();
} catch (Exception ex) {
}
// Lets not search overly large exception stacks, if the exception name
// isn't in the first bit of the string then it's probably not there at all.
errorDetail.substring(0, Math.min(100, errorDetail.length()));
if (errorDetail.contains(INVALID_CLIENTID_EXCEPTION)) {
exception = new InvalidClientIDException(frame.getErrorMessage());
} else if (errorDetail.contains(INVALID_SELECTOR_EXCEPTION)) {
exception = new InvalidSelectorException(frame.getErrorMessage());
} else if (errorDetail.contains(JMS_SECURITY_EXCEPTION)) {
exception = new JMSSecurityException(frame.getErrorMessage());
} else if (errorDetail.contains(SECURITY_EXCEPTION)) {
exception = new JMSSecurityException(frame.getErrorMessage());
} else {
exception = new JMSException(frame.getErrorMessage());
}
return exception;
}
/**
* @return the STOMP based JmsMessageFactory for this Connection.
*/
public StompJmsMessageFactory getMessageFactory() {
return this.messageFactory;
}
}