diff --git a/tests/artemis-test-support/pom.xml b/tests/artemis-test-support/pom.xml new file mode 100644 index 0000000000..ec0c49d585 --- /dev/null +++ b/tests/artemis-test-support/pom.xml @@ -0,0 +1,57 @@ + + + 4.0.0 + + org.apache.activemq.tests + artemis-tests-pom + 1.4.0-SNAPSHOT + + + artemis-test-support + jar + ActiveMQ Artemis Test Support + + + ${project.basedir}/../.. + + + + + org.apache.qpid + proton-j + + + org.apache.qpid + qpid-jms-client + + + org.slf4j + slf4j-api + + + io.netty + netty-all + + + org.apache.activemq + activemq-client + + + + diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/AmqpProtocolException.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/AmqpProtocolException.java new file mode 100644 index 0000000000..6e584172b2 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/AmqpProtocolException.java @@ -0,0 +1,62 @@ +/** + * 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 org.apache.activemq.transport.amqp; + +import java.io.IOException; + +public class AmqpProtocolException extends IOException { + + private static final long serialVersionUID = -2869735532997332242L; + + private final String symbolicName; + private final boolean fatal; + + public AmqpProtocolException() { + this(null); + } + + public AmqpProtocolException(String s) { + this(s, false); + } + + public AmqpProtocolException(String s, boolean fatal) { + this(s, fatal, null); + } + + public AmqpProtocolException(String s, String msg) { + this(s, msg, false, null); + } + + public AmqpProtocolException(String s, boolean fatal, Throwable cause) { + this("error", s, fatal, cause); + } + + public AmqpProtocolException(String symbolicName, String s, boolean fatal, Throwable cause) { + super(s); + this.symbolicName = symbolicName; + this.fatal = fatal; + initCause(cause); + } + + public boolean isFatal() { + return fatal; + } + + public String getSymbolicName() { + return symbolicName; + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/AmqpSupport.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/AmqpSupport.java new file mode 100644 index 0000000000..cde4defed5 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/AmqpSupport.java @@ -0,0 +1,206 @@ +/** + * 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 org.apache.activemq.transport.amqp; + +import java.nio.ByteBuffer; +import java.util.AbstractMap; +import java.util.Map; + +import org.apache.activemq.command.ActiveMQDestination; +import org.apache.qpid.proton.amqp.Binary; +import org.apache.qpid.proton.amqp.DescribedType; +import org.apache.qpid.proton.amqp.Symbol; +import org.apache.qpid.proton.amqp.UnsignedLong; +import org.apache.qpid.proton.amqp.transaction.Coordinator; +import org.fusesource.hawtbuf.Buffer; + +/** + * Set of useful methods and definitions used in the AMQP protocol handling + */ +public class AmqpSupport { + + // Identification values used to locating JMS selector types. + public static final UnsignedLong JMS_SELECTOR_CODE = UnsignedLong.valueOf(0x0000468C00000004L); + public static final Symbol JMS_SELECTOR_NAME = Symbol.valueOf("apache.org:selector-filter:string"); + public static final Object[] JMS_SELECTOR_FILTER_IDS = new Object[]{JMS_SELECTOR_CODE, JMS_SELECTOR_NAME}; + public static final UnsignedLong NO_LOCAL_CODE = UnsignedLong.valueOf(0x0000468C00000003L); + public static final Symbol NO_LOCAL_NAME = Symbol.valueOf("apache.org:no-local-filter:list"); + public static final Object[] NO_LOCAL_FILTER_IDS = new Object[]{NO_LOCAL_CODE, NO_LOCAL_NAME}; + + // Capabilities used to identify destination type in some requests. + public static final Symbol TEMP_QUEUE_CAPABILITY = Symbol.valueOf("temporary-queue"); + public static final Symbol TEMP_TOPIC_CAPABILITY = Symbol.valueOf("temporary-topic"); + + // Symbols used to announce connection information to remote peer. + public static final Symbol INVALID_FIELD = Symbol.valueOf("invalid-field"); + public static final Symbol CONTAINER_ID = Symbol.valueOf("container-id"); + + // Symbols used to announce connection information to remote peer. + public static final Symbol ANONYMOUS_RELAY = Symbol.valueOf("ANONYMOUS-RELAY"); + public static final Symbol QUEUE_PREFIX = Symbol.valueOf("queue-prefix"); + public static final Symbol TOPIC_PREFIX = Symbol.valueOf("topic-prefix"); + public static final Symbol CONNECTION_OPEN_FAILED = Symbol.valueOf("amqp:connection-establishment-failed"); + public static final Symbol PRODUCT = Symbol.valueOf("product"); + public static final Symbol VERSION = Symbol.valueOf("version"); + public static final Symbol PLATFORM = Symbol.valueOf("platform"); + + // Symbols used in configuration of newly opened links. + public static final Symbol COPY = Symbol.getSymbol("copy"); + + // Lifetime policy symbols + public static final Symbol LIFETIME_POLICY = Symbol.valueOf("lifetime-policy"); + + /** + * Search for a given Symbol in a given array of Symbol object. + * + * @param symbols the set of Symbols to search. + * @param key the value to try and find in the Symbol array. + * @return true if the key is found in the given Symbol array. + */ + public static boolean contains(Symbol[] symbols, Symbol key) { + if (symbols == null || symbols.length == 0) { + return false; + } + + for (Symbol symbol : symbols) { + if (symbol.equals(key)) { + return true; + } + } + + return false; + } + + /** + * Search for a particular filter using a set of known indentification values + * in the Map of filters. + * + * @param filters The filters map that should be searched. + * @param filterIds The aliases for the target filter to be located. + * @return the filter if found in the mapping or null if not found. + */ + public static Map.Entry findFilter(Map filters, Object[] filterIds) { + + if (filterIds == null || filterIds.length == 0) { + throw new IllegalArgumentException("Invalid empty Filter Ids array passed: "); + } + + if (filters == null || filters.isEmpty()) { + return null; + } + + for (Map.Entry filter : filters.entrySet()) { + if (filter.getValue() instanceof DescribedType) { + DescribedType describedType = ((DescribedType) filter.getValue()); + Object descriptor = describedType.getDescriptor(); + + for (Object filterId : filterIds) { + if (descriptor.equals(filterId)) { + return new AbstractMap.SimpleImmutableEntry<>(filter.getKey(), describedType); + } + } + } + } + + return null; + } + + /** + * Conversion from Java ByteBuffer to a HawtBuf buffer. + * + * @param data the ByteBuffer instance to convert. + * @return a new HawtBuf buffer converted from the given ByteBuffer. + */ + public static Buffer toBuffer(ByteBuffer data) { + if (data == null) { + return null; + } + + Buffer rc; + + if (data.isDirect()) { + rc = new Buffer(data.remaining()); + data.get(rc.data); + } + else { + rc = new Buffer(data); + data.position(data.position() + data.remaining()); + } + + return rc; + } + + /** + * Given a long value, convert it to a byte array for marshalling. + * + * @param value the value to convert. + * @return a new byte array that holds the big endian value of the long. + */ + public static byte[] toBytes(long value) { + Buffer buffer = new Buffer(8); + buffer.bigEndianEditor().writeLong(value); + return buffer.data; + } + + /** + * Converts a Binary value to a long assuming that the contained value is + * stored in Big Endian encoding. + * + * @param value the Binary object whose payload is converted to a long. + * @return a long value constructed from the bytes of the Binary instance. + */ + public static long toLong(Binary value) { + Buffer buffer = new Buffer(value.getArray(), value.getArrayOffset(), value.getLength()); + return buffer.bigEndianEditor().readLong(); + } + + /** + * Given an AMQP endpoint, deduce the appropriate ActiveMQDestination type and create + * a new instance. By default if the endpoint address does not carry the standard prefix + * value then we default to a Queue type destination. If the endpoint is null or is an + * AMQP Coordinator type endpoint this method returns null to indicate no destination + * can be mapped. + * + * @param endpoint the AMQP endpoint to construct an ActiveMQDestination from. + * @return a new ActiveMQDestination that best matches the address of the given endpoint + * @throws AmqpProtocolException if an error occurs while deducing the destination type. + */ + public static ActiveMQDestination createDestination(Object endpoint) throws AmqpProtocolException { + if (endpoint == null) { + return null; + } + else if (endpoint instanceof Coordinator) { + return null; + } + else if (endpoint instanceof org.apache.qpid.proton.amqp.messaging.Terminus) { + org.apache.qpid.proton.amqp.messaging.Terminus terminus = (org.apache.qpid.proton.amqp.messaging.Terminus) endpoint; + if (terminus.getAddress() == null || terminus.getAddress().length() == 0) { + if (terminus instanceof org.apache.qpid.proton.amqp.messaging.Source) { + throw new AmqpProtocolException("amqp:invalid-field", "source address not set"); + } + else { + throw new AmqpProtocolException("amqp:invalid-field", "target address not set"); + } + } + + return ActiveMQDestination.createDestination(terminus.getAddress(), ActiveMQDestination.QUEUE_TYPE); + } + else { + throw new RuntimeException("Unexpected terminus type: " + endpoint); + } + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpAbstractResource.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpAbstractResource.java new file mode 100644 index 0000000000..b99c56b79f --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpAbstractResource.java @@ -0,0 +1,321 @@ +/** + * 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 org.apache.activemq.transport.amqp.client; + +import java.io.IOException; + +import org.apache.activemq.transport.amqp.client.util.AsyncResult; +import org.apache.qpid.proton.engine.Endpoint; +import org.apache.qpid.proton.engine.EndpointState; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Abstract base for all AmqpResource implementations to extend. + * + * This abstract class wraps up the basic state management bits so that the concrete + * object don't have to reproduce it. Provides hooks for the subclasses to initialize + * and shutdown. + */ +public abstract class AmqpAbstractResource implements AmqpResource { + + private static final Logger LOG = LoggerFactory.getLogger(AmqpAbstractResource.class); + + protected AsyncResult openRequest; + protected AsyncResult closeRequest; + + private AmqpValidator amqpStateInspector; + + private E endpoint; + + @Override + public void open(AsyncResult request) { + this.openRequest = request; + doOpen(); + getEndpoint().setContext(this); + } + + @Override + public boolean isOpen() { + return getEndpoint().getRemoteState() == EndpointState.ACTIVE; + } + + @Override + public void opened() { + if (this.openRequest != null) { + this.openRequest.onSuccess(); + this.openRequest = null; + } + } + + @Override + public void detach(AsyncResult request) { + // If already closed signal success or else the caller might never get notified. + if (getEndpoint().getLocalState() == EndpointState.CLOSED || getEndpoint().getRemoteState() == EndpointState.CLOSED) { + + if (getEndpoint().getLocalState() != EndpointState.CLOSED) { + doDetach(); + getEndpoint().free(); + } + + request.onSuccess(); + } + else { + this.closeRequest = request; + doDetach(); + } + } + + @Override + public void close(AsyncResult request) { + // If already closed signal success or else the caller might never get notified. + if (getEndpoint().getLocalState() == EndpointState.CLOSED || getEndpoint().getRemoteState() == EndpointState.CLOSED) { + + if (getEndpoint().getLocalState() != EndpointState.CLOSED) { + doClose(); + getEndpoint().free(); + } + + request.onSuccess(); + } + else { + this.closeRequest = request; + doClose(); + } + } + + @Override + public boolean isClosed() { + return getEndpoint().getLocalState() == EndpointState.CLOSED; + } + + @Override + public void closed() { + getEndpoint().close(); + getEndpoint().free(); + + if (this.closeRequest != null) { + this.closeRequest.onSuccess(); + this.closeRequest = null; + } + } + + @Override + public void failed() { + failed(new Exception("Remote request failed.")); + } + + @Override + public void failed(Exception cause) { + if (openRequest != null) { + if (endpoint != null) { + // TODO: if this is a producer/consumer link then we may only be detached, + // rather than fully closed, and should respond appropriately. + endpoint.close(); + } + openRequest.onFailure(cause); + openRequest = null; + } + + if (closeRequest != null) { + closeRequest.onFailure(cause); + closeRequest = null; + } + } + + @Override + public void remotelyClosed(AmqpConnection connection) { + Exception error = AmqpSupport.convertToException(getEndpoint().getRemoteCondition()); + + if (endpoint != null) { + // TODO: if this is a producer/consumer link then we may only be detached, + // rather than fully closed, and should respond appropriately. + endpoint.close(); + } + + LOG.info("Resource {} was remotely closed", this); + + connection.fireClientException(error); + } + + @Override + public void locallyClosed(AmqpConnection connection, Exception error) { + if (endpoint != null) { + // TODO: if this is a producer/consumer link then we may only be detached, + // rather than fully closed, and should respond appropriately. + endpoint.close(); + } + + LOG.info("Resource {} was locally closed", this); + + connection.fireClientException(error); + } + + public E getEndpoint() { + return this.endpoint; + } + + public void setEndpoint(E endpoint) { + this.endpoint = endpoint; + } + + public AmqpValidator getStateInspector() { + return amqpStateInspector; + } + + public void setStateInspector(AmqpValidator stateInspector) { + if (stateInspector == null) { + stateInspector = new AmqpValidator(); + } + + this.amqpStateInspector = stateInspector; + } + + public EndpointState getLocalState() { + if (getEndpoint() == null) { + return EndpointState.UNINITIALIZED; + } + return getEndpoint().getLocalState(); + } + + public EndpointState getRemoteState() { + if (getEndpoint() == null) { + return EndpointState.UNINITIALIZED; + } + return getEndpoint().getRemoteState(); + } + + public boolean hasRemoteError() { + return getEndpoint().getRemoteCondition().getCondition() != null; + } + + @Override + public void processRemoteOpen(AmqpConnection connection) throws IOException { + doOpenInspection(); + doOpenCompletion(); + } + + @Override + public void processRemoteDetach(AmqpConnection connection) throws IOException { + doDetachedInspection(); + if (isAwaitingClose()) { + LOG.debug("{} is now closed: ", this); + closed(); + } + else { + remotelyClosed(connection); + } + } + + @Override + public void processRemoteClose(AmqpConnection connection) throws IOException { + doClosedInspection(); + if (isAwaitingClose()) { + LOG.debug("{} is now closed: ", this); + closed(); + } + else if (isAwaitingOpen()) { + // Error on Open, create exception and signal failure. + LOG.warn("Open of {} failed: ", this); + Exception openError; + if (hasRemoteError()) { + openError = AmqpSupport.convertToException(getEndpoint().getRemoteCondition()); + } + else { + openError = getOpenAbortException(); + } + + failed(openError); + } + else { + remotelyClosed(connection); + } + } + + @Override + public void processDeliveryUpdates(AmqpConnection connection) throws IOException { + } + + @Override + public void processFlowUpdates(AmqpConnection connection) throws IOException { + } + + /** + * Perform the open operation on the managed endpoint. A subclass may + * override this method to provide additional open actions or configuration + * updates. + */ + protected void doOpen() { + getEndpoint().open(); + } + + /** + * Perform the close operation on the managed endpoint. A subclass may + * override this method to provide additional close actions or alter the + * standard close path such as endpoint detach etc. + */ + protected void doClose() { + getEndpoint().close(); + } + + /** + * Perform the detach operation on the managed endpoint. + * + * By default this method throws an UnsupportedOperationException, a subclass + * must implement this and do a detach if its resource supports that. + */ + protected void doDetach() { + throw new UnsupportedOperationException("Endpoint cannot be detached."); + } + + /** + * Complete the open operation on the managed endpoint. A subclass may + * override this method to provide additional verification actions or configuration + * updates. + */ + protected void doOpenCompletion() { + LOG.debug("{} is now open: ", this); + opened(); + } + + /** + * When aborting the open operation, and there isnt an error condition, + * provided by the peer, the returned exception will be used instead. + * A subclass may override this method to provide alternative behaviour. + */ + protected Exception getOpenAbortException() { + return new IOException("Open failed unexpectedly."); + } + + // TODO - Fina a more generic way to do this. + protected abstract void doOpenInspection(); + + protected abstract void doClosedInspection(); + + protected void doDetachedInspection() { + } + + //----- Private implementation utility methods ---------------------------// + + private boolean isAwaitingOpen() { + return this.openRequest != null; + } + + private boolean isAwaitingClose() { + return this.closeRequest != null; + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpClient.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpClient.java new file mode 100644 index 0000000000..001942ed4e --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpClient.java @@ -0,0 +1,245 @@ +/** + * 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 org.apache.activemq.transport.amqp.client; + +import java.net.URI; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.apache.activemq.transport.amqp.client.transport.NettyTransport; +import org.apache.activemq.transport.amqp.client.transport.NettyTransportFactory; +import org.apache.qpid.proton.amqp.Symbol; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Connection instance used to connect to the Broker using Proton as + * the AMQP protocol handler. + */ +public class AmqpClient { + + private static final Logger LOG = LoggerFactory.getLogger(AmqpClient.class); + + private final String username; + private final String password; + private final URI remoteURI; + private String authzid; + private String mechanismRestriction; + + private AmqpValidator stateInspector = new AmqpValidator(); + private List offeredCapabilities = Collections.emptyList(); + private Map offeredProperties = Collections.emptyMap(); + + /** + * Creates an AmqpClient instance which can be used as a factory for connections. + * + * @param remoteURI The address of the remote peer to connect to. + * @param username The user name to use when authenticating the client. + * @param password The password to use when authenticating the client. + */ + public AmqpClient(URI remoteURI, String username, String password) { + this.remoteURI = remoteURI; + this.password = password; + this.username = username; + } + + /** + * Creates a connection with the broker at the given location, this method initiates a + * connect attempt immediately and will fail if the remote peer cannot be reached. + * + * @throws Exception if an error occurs attempting to connect to the Broker. + * @returns a new connection object used to interact with the connected peer. + */ + public AmqpConnection connect() throws Exception { + + AmqpConnection connection = createConnection(); + + LOG.debug("Attempting to create new connection to peer: {}", remoteURI); + connection.connect(); + + return connection; + } + + /** + * Creates a connection object using the configured values for user, password, remote URI + * etc. This method does not immediately initiate a connection to the remote leaving that + * to the caller which provides a connection object that can have additional configuration + * changes applied before the connect method is invoked. + * + * @throws Exception if an error occurs attempting to connect to the Broker. + * @returns a new connection object used to interact with the connected peer. + */ + public AmqpConnection createConnection() throws Exception { + if (username == null && password != null) { + throw new IllegalArgumentException("Password must be null if user name value is null"); + } + + NettyTransport transport = NettyTransportFactory.createTransport(remoteURI); + AmqpConnection connection = new AmqpConnection(transport, username, password); + + connection.setMechanismRestriction(mechanismRestriction); + connection.setAuthzid(authzid); + + connection.setOfferedCapabilities(getOfferedCapabilities()); + connection.setOfferedProperties(getOfferedProperties()); + connection.setStateInspector(getStateInspector()); + + return connection; + } + + /** + * @return the user name value given when constructed. + */ + public String getUsername() { + return username; + } + + /** + * @return the password value given when constructed. + */ + public String getPassword() { + return password; + } + + /** + * @param authzid The authzid used when authenticating (currently only with PLAIN) + */ + public void setAuthzid(String authzid) { + this.authzid = authzid; + } + + public String getAuthzid() { + return authzid; + } + + /** + * @param mechanismRestriction The mechanism to use when authenticating (if offered by the server) + */ + public void setMechanismRestriction(String mechanismRestriction) { + this.mechanismRestriction = mechanismRestriction; + } + + public String getMechanismRestriction() { + return mechanismRestriction; + } + + /** + * @return the currently set address to use to connect to the AMQP peer. + */ + public URI getRemoteURI() { + return remoteURI; + } + + /** + * Sets the offered capabilities that should be used when a new connection attempt + * is made. + * + * @param offeredCapabilities the list of capabilities to offer when connecting. + */ + public void setOfferedCapabilities(List offeredCapabilities) { + if (offeredCapabilities != null) { + offeredCapabilities = Collections.emptyList(); + } + + this.offeredCapabilities = offeredCapabilities; + } + + /** + * @return an unmodifiable view of the currently set offered capabilities + */ + public List getOfferedCapabilities() { + return Collections.unmodifiableList(offeredCapabilities); + } + + /** + * Sets the offered connection properties that should be used when a new connection + * attempt is made. + * + * @param offeredProperties the map of properties to offer when connecting. + */ + public void setOfferedProperties(Map offeredProperties) { + if (offeredProperties != null) { + offeredProperties = Collections.emptyMap(); + } + + this.offeredProperties = offeredProperties; + } + + /** + * @return an unmodifiable view of the currently set connection properties. + */ + public Map getOfferedProperties() { + return Collections.unmodifiableMap(offeredProperties); + } + + /** + * @return the currently set state inspector used to check state after various events. + */ + public AmqpValidator getStateInspector() { + return stateInspector; + } + + /** + * Sets the state inspector used to check that the AMQP resource is valid after + * specific lifecycle events such as open and close. + * + * @param stateInspector the new state inspector to use. + */ + public void setValidator(AmqpValidator stateInspector) { + if (stateInspector == null) { + stateInspector = new AmqpValidator(); + } + + this.stateInspector = stateInspector; + } + + @Override + public String toString() { + return "AmqpClient: " + getRemoteURI().getHost() + ":" + getRemoteURI().getPort(); + } + + /** + * Creates an anonymous connection with the broker at the given location. + * + * @param broker the address of the remote broker instance. + * @throws Exception if an error occurs attempting to connect to the Broker. + * @returns a new connection object used to interact with the connected peer. + */ + public static AmqpConnection connect(URI broker) throws Exception { + return connect(broker, null, null); + } + + /** + * Creates a connection with the broker at the given location. + * + * @param broker the address of the remote broker instance. + * @param username the user name to use to connect to the broker or null for anonymous. + * @param password the password to use to connect to the broker, must be null if user name is null. + * @throws Exception if an error occurs attempting to connect to the Broker. + * @returns a new connection object used to interact with the connected peer. + */ + public static AmqpConnection connect(URI broker, String username, String password) throws Exception { + if (username == null && password != null) { + throw new IllegalArgumentException("Password must be null if user name value is null"); + } + + AmqpClient client = new AmqpClient(broker, username, password); + + return client.connect(); + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpConnection.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpConnection.java new file mode 100644 index 0000000000..1454dd9a0a --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpConnection.java @@ -0,0 +1,720 @@ +/** + * 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 org.apache.activemq.transport.amqp.client; + +import java.io.IOException; +import java.net.URI; +import java.nio.ByteBuffer; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.util.ReferenceCountUtil; +import org.apache.activemq.transport.InactivityIOException; +import org.apache.activemq.transport.amqp.client.sasl.SaslAuthenticator; +import org.apache.activemq.transport.amqp.client.transport.NettyTransportListener; +import org.apache.activemq.transport.amqp.client.util.AsyncResult; +import org.apache.activemq.transport.amqp.client.util.ClientFuture; +import org.apache.activemq.transport.amqp.client.util.IdGenerator; +import org.apache.activemq.transport.amqp.client.util.NoOpAsyncResult; +import org.apache.activemq.transport.amqp.client.util.UnmodifiableConnection; +import org.apache.qpid.proton.amqp.Symbol; +import org.apache.qpid.proton.engine.Collector; +import org.apache.qpid.proton.engine.Connection; +import org.apache.qpid.proton.engine.EndpointState; +import org.apache.qpid.proton.engine.Event; +import org.apache.qpid.proton.engine.Event.Type; +import org.apache.qpid.proton.engine.Sasl; +import org.apache.qpid.proton.engine.Transport; +import org.apache.qpid.proton.engine.impl.CollectorImpl; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static org.apache.activemq.transport.amqp.AmqpSupport.CONNECTION_OPEN_FAILED; + +public class AmqpConnection extends AmqpAbstractResource implements NettyTransportListener { + + private static final Logger LOG = LoggerFactory.getLogger(AmqpConnection.class); + + private static final NoOpAsyncResult NOOP_REQUEST = new NoOpAsyncResult(); + + private static final int DEFAULT_MAX_FRAME_SIZE = 1024 * 1024 * 1; + // NOTE: Limit default channel max to signed short range to deal with + // brokers that don't currently handle the unsigned range well. + private static final int DEFAULT_CHANNEL_MAX = 32767; + private static final IdGenerator CONNECTION_ID_GENERATOR = new IdGenerator(); + + public static final long DEFAULT_CONNECT_TIMEOUT = 515000; + public static final long DEFAULT_CLOSE_TIMEOUT = 30000; + public static final long DEFAULT_DRAIN_TIMEOUT = 60000; + + private final ScheduledExecutorService serializer; + private final AtomicBoolean closed = new AtomicBoolean(); + private final AtomicBoolean connected = new AtomicBoolean(); + private final AtomicLong sessionIdGenerator = new AtomicLong(); + private final AtomicLong txIdGenerator = new AtomicLong(); + private final Collector protonCollector = new CollectorImpl(); + private final org.apache.activemq.transport.amqp.client.transport.NettyTransport transport; + private final Transport protonTransport = Transport.Factory.create(); + + private final String username; + private final String password; + private final URI remoteURI; + private final String connectionId; + private List offeredCapabilities = Collections.emptyList(); + private Map offeredProperties = Collections.emptyMap(); + + private AmqpConnectionListener listener; + private SaslAuthenticator authenticator; + private String mechanismRestriction; + private String authzid; + + private int idleTimeout = 0; + private boolean idleProcessingDisabled; + private String containerId; + private boolean authenticated; + private int channelMax = DEFAULT_CHANNEL_MAX; + private long connectTimeout = DEFAULT_CONNECT_TIMEOUT; + private long closeTimeout = DEFAULT_CLOSE_TIMEOUT; + private long drainTimeout = DEFAULT_DRAIN_TIMEOUT; + + public AmqpConnection(org.apache.activemq.transport.amqp.client.transport.NettyTransport transport, + String username, + String password) { + setEndpoint(Connection.Factory.create()); + getEndpoint().collect(protonCollector); + + this.transport = transport; + this.username = username; + this.password = password; + this.connectionId = CONNECTION_ID_GENERATOR.generateId(); + this.remoteURI = transport.getRemoteLocation(); + + this.serializer = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() { + + @Override + public Thread newThread(Runnable runner) { + Thread serial = new Thread(runner); + serial.setDaemon(true); + serial.setName(toString()); + return serial; + } + }); + + this.transport.setTransportListener(this); + } + + public void connect() throws Exception { + if (connected.compareAndSet(false, true)) { + transport.connect(); + + final ClientFuture future = new ClientFuture(); + serializer.execute(new Runnable() { + @Override + public void run() { + getEndpoint().setContainer(safeGetContainerId()); + getEndpoint().setHostname(remoteURI.getHost()); + if (!getOfferedCapabilities().isEmpty()) { + getEndpoint().setOfferedCapabilities(getOfferedCapabilities().toArray(new Symbol[0])); + } + if (!getOfferedProperties().isEmpty()) { + getEndpoint().setProperties(getOfferedProperties()); + } + + if (getIdleTimeout() > 0) { + protonTransport.setIdleTimeout(getIdleTimeout()); + } + protonTransport.setMaxFrameSize(getMaxFrameSize()); + protonTransport.setChannelMax(getChannelMax()); + protonTransport.bind(getEndpoint()); + Sasl sasl = protonTransport.sasl(); + if (sasl != null) { + sasl.client(); + } + authenticator = new SaslAuthenticator(sasl, username, password, authzid, mechanismRestriction); + open(future); + + pumpToProtonTransport(future); + } + }); + + if (connectTimeout <= 0) { + future.sync(); + } + else { + future.sync(connectTimeout, TimeUnit.MILLISECONDS); + if (getEndpoint().getRemoteState() != EndpointState.ACTIVE) { + throw new IOException("Failed to connect after configured timeout."); + } + } + } + } + + public boolean isConnected() { + return transport.isConnected() && connected.get(); + } + + public void close() { + if (closed.compareAndSet(false, true)) { + final ClientFuture request = new ClientFuture(); + serializer.execute(new Runnable() { + + @Override + public void run() { + try { + + // If we are not connected then there is nothing we can do now + // just signal success. + if (!transport.isConnected()) { + request.onSuccess(); + } + + if (getEndpoint() != null) { + close(request); + } + else { + request.onSuccess(); + } + + pumpToProtonTransport(request); + } + catch (Exception e) { + LOG.debug("Caught exception while closing proton connection"); + } + } + }); + + try { + if (closeTimeout <= 0) { + request.sync(); + } + else { + request.sync(closeTimeout, TimeUnit.MILLISECONDS); + } + } + catch (IOException e) { + LOG.warn("Error caught while closing Provider: ", e.getMessage()); + } + finally { + if (transport != null) { + try { + transport.close(); + } + catch (Exception e) { + LOG.debug("Cuaght exception while closing down Transport: {}", e.getMessage()); + } + } + + serializer.shutdown(); + } + } + } + + /** + * Creates a new Session instance used to create AMQP resources like + * senders and receivers. + * + * @return a new AmqpSession that can be used to create links. + * @throws Exception if an error occurs during creation. + */ + public AmqpSession createSession() throws Exception { + checkClosed(); + + final AmqpSession session = new AmqpSession(AmqpConnection.this, getNextSessionId()); + final ClientFuture request = new ClientFuture(); + + serializer.execute(new Runnable() { + + @Override + public void run() { + checkClosed(); + session.setEndpoint(getEndpoint().session()); + session.setStateInspector(getStateInspector()); + session.open(request); + pumpToProtonTransport(request); + } + }); + + request.sync(); + + return session; + } + + //----- Access to low level IO for specific test cases -------------------// + + public void sendRawBytes(final byte[] rawData) throws Exception { + checkClosed(); + + final ClientFuture request = new ClientFuture(); + + serializer.execute(new Runnable() { + + @Override + public void run() { + checkClosed(); + try { + transport.send(Unpooled.wrappedBuffer(rawData)); + } + catch (IOException e) { + fireClientException(e); + } + finally { + request.onSuccess(); + } + } + }); + + request.sync(); + } + + //----- Configuration accessors ------------------------------------------// + + /** + * @return the user name that was used to authenticate this connection. + */ + public String getUsername() { + return username; + } + + /** + * @return the password that was used to authenticate this connection. + */ + public String getPassword() { + return password; + } + + public void setAuthzid(String authzid) { + this.authzid = authzid; + } + + public String getAuthzid() { + return authzid; + } + + /** + * @return the URI of the remote peer this connection attached to. + */ + public URI getRemoteURI() { + return remoteURI; + } + + /** + * @return the container ID that will be set as the container Id. + */ + public String getContainerId() { + return this.containerId; + } + + /** + * Sets the container Id that will be configured on the connection prior to + * connecting to the remote peer. Calling this after connect has no effect. + * + * @param containerId the container Id to use on the connection. + */ + public void setContainerId(String containerId) { + this.containerId = containerId; + } + + /** + * @return the currently set Max Frame Size value. + */ + public int getMaxFrameSize() { + return DEFAULT_MAX_FRAME_SIZE; + } + + public int getChannelMax() { + return channelMax; + } + + public void setChannelMax(int channelMax) { + this.channelMax = channelMax; + } + + public long getConnectTimeout() { + return connectTimeout; + } + + public void setConnectTimeout(long connectTimeout) { + this.connectTimeout = connectTimeout; + } + + public long getCloseTimeout() { + return closeTimeout; + } + + public void setCloseTimeout(long closeTimeout) { + this.closeTimeout = closeTimeout; + } + + public long getDrainTimeout() { + return drainTimeout; + } + + public void setDrainTimeout(long drainTimeout) { + this.drainTimeout = drainTimeout; + } + + public List getOfferedCapabilities() { + return offeredCapabilities; + } + + public void setOfferedCapabilities(List offeredCapabilities) { + if (offeredCapabilities != null) { + offeredCapabilities = Collections.emptyList(); + } + + this.offeredCapabilities = offeredCapabilities; + } + + public Map getOfferedProperties() { + return offeredProperties; + } + + public void setOfferedProperties(Map offeredProperties) { + if (offeredProperties != null) { + offeredProperties = Collections.emptyMap(); + } + + this.offeredProperties = offeredProperties; + } + + public Connection getConnection() { + return new UnmodifiableConnection(getEndpoint()); + } + + public AmqpConnectionListener getListener() { + return listener; + } + + public void setListener(AmqpConnectionListener listener) { + this.listener = listener; + } + + public int getIdleTimeout() { + return idleTimeout; + } + + public void setIdleTimeout(int idleTimeout) { + this.idleTimeout = idleTimeout; + } + + public void setIdleProcessingDisabled(boolean value) { + this.idleProcessingDisabled = value; + } + + public boolean isIdleProcessingDisabled() { + return idleProcessingDisabled; + } + + /** + * Sets a restriction on the SASL mechanism to use (if offered by the server). + * + * @param mechanismRestriction the mechanism to use + */ + public void setMechanismRestriction(String mechanismRestriction) { + this.mechanismRestriction = mechanismRestriction; + } + + public String getMechanismRestriction() { + return mechanismRestriction; + } + + //----- Internal getters used from the child AmqpResource classes --------// + + ScheduledExecutorService getScheduler() { + return this.serializer; + } + + Connection getProtonConnection() { + return getEndpoint(); + } + + String getConnectionId() { + return this.connectionId; + } + + AmqpTransactionId getNextTransactionId() { + return new AmqpTransactionId(connectionId + ":" + txIdGenerator.incrementAndGet()); + } + + void pumpToProtonTransport() { + pumpToProtonTransport(NOOP_REQUEST); + } + + void pumpToProtonTransport(AsyncResult request) { + try { + boolean done = false; + while (!done) { + ByteBuffer toWrite = protonTransport.getOutputBuffer(); + if (toWrite != null && toWrite.hasRemaining()) { + ByteBuf outbound = transport.allocateSendBuffer(toWrite.remaining()); + outbound.writeBytes(toWrite); + transport.send(outbound); + protonTransport.outputConsumed(); + } + else { + done = true; + } + } + } + catch (IOException e) { + fireClientException(e); + request.onFailure(e); + } + } + + //----- Transport listener event hooks -----------------------------------// + + @Override + public void onData(final ByteBuf incoming) { + + // We need to retain until the serializer gets around to processing it. + ReferenceCountUtil.retain(incoming); + + serializer.execute(new Runnable() { + + @Override + public void run() { + ByteBuffer source = incoming.nioBuffer(); + LOG.trace("Client Received from Broker {} bytes:", source.remaining()); + + if (protonTransport.isClosed()) { + LOG.debug("Ignoring incoming data because transport is closed"); + return; + } + + do { + ByteBuffer buffer = protonTransport.getInputBuffer(); + int limit = Math.min(buffer.remaining(), source.remaining()); + ByteBuffer duplicate = source.duplicate(); + duplicate.limit(source.position() + limit); + buffer.put(duplicate); + protonTransport.processInput(); + source.position(source.position() + limit); + } while (source.hasRemaining()); + + ReferenceCountUtil.release(incoming); + + // Process the state changes from the latest data and then answer back + // any pending updates to the Broker. + processUpdates(); + pumpToProtonTransport(); + } + }); + } + + @Override + public void onTransportClosed() { + LOG.debug("The transport has unexpectedly closed"); + failed(getOpenAbortException()); + } + + @Override + public void onTransportError(Throwable cause) { + fireClientException(cause); + } + + //----- Internal implementation ------------------------------------------// + + @Override + protected void doOpenCompletion() { + // If the remote indicates that a close is pending, don't open. + if (getEndpoint().getRemoteProperties() == null || !getEndpoint().getRemoteProperties().containsKey(CONNECTION_OPEN_FAILED)) { + + if (!isIdleProcessingDisabled()) { + // Using nano time since it is not related to the wall clock, which may change + long initialNow = TimeUnit.NANOSECONDS.toMillis(System.nanoTime()); + long initialKeepAliveDeadline = protonTransport.tick(initialNow); + if (initialKeepAliveDeadline > 0) { + + getScheduler().schedule(new Runnable() { + + @Override + public void run() { + try { + if (getEndpoint().getLocalState() != EndpointState.CLOSED) { + LOG.debug("Client performing next idle check"); + // Using nano time since it is not related to the wall clock, which may change + long now = TimeUnit.NANOSECONDS.toMillis(System.nanoTime()); + long rescheduleAt = protonTransport.tick(now) - now; + pumpToProtonTransport(); + if (protonTransport.isClosed()) { + LOG.debug("Transport closed after inactivity check."); + throw new InactivityIOException("Channel was inactive for to long"); + } + + if (rescheduleAt > 0) { + getScheduler().schedule(this, rescheduleAt, TimeUnit.MILLISECONDS); + } + } + } + catch (Exception e) { + try { + transport.close(); + } + catch (IOException e1) { + } + fireClientException(e); + } + } + }, initialKeepAliveDeadline - initialNow, TimeUnit.MILLISECONDS); + } + } + super.doOpenCompletion(); + } + } + + @Override + protected void doOpenInspection() { + try { + getStateInspector().inspectOpenedResource(getConnection()); + } + catch (Throwable error) { + getStateInspector().markAsInvalid(error.getMessage()); + } + } + + @Override + protected void doClosedInspection() { + try { + getStateInspector().inspectClosedResource(getConnection()); + } + catch (Throwable error) { + getStateInspector().markAsInvalid(error.getMessage()); + } + } + + protected void fireClientException(Throwable ex) { + AmqpConnectionListener listener = this.listener; + if (listener != null) { + listener.onException(ex); + } + } + + protected void checkClosed() throws IllegalStateException { + if (closed.get()) { + throw new IllegalStateException("The Connection is already closed"); + } + } + + private void processUpdates() { + try { + Event protonEvent = null; + while ((protonEvent = protonCollector.peek()) != null) { + if (!protonEvent.getType().equals(Type.TRANSPORT)) { + LOG.trace("Client: New Proton Event: {}", protonEvent.getType()); + } + + AmqpEventSink amqpEventSink = null; + switch (protonEvent.getType()) { + case CONNECTION_REMOTE_CLOSE: + amqpEventSink = (AmqpEventSink) protonEvent.getConnection().getContext(); + amqpEventSink.processRemoteClose(this); + break; + case CONNECTION_REMOTE_OPEN: + amqpEventSink = (AmqpEventSink) protonEvent.getConnection().getContext(); + amqpEventSink.processRemoteOpen(this); + break; + case SESSION_REMOTE_CLOSE: + amqpEventSink = (AmqpEventSink) protonEvent.getSession().getContext(); + amqpEventSink.processRemoteClose(this); + break; + case SESSION_REMOTE_OPEN: + amqpEventSink = (AmqpEventSink) protonEvent.getSession().getContext(); + amqpEventSink.processRemoteOpen(this); + break; + case LINK_REMOTE_CLOSE: + amqpEventSink = (AmqpEventSink) protonEvent.getLink().getContext(); + amqpEventSink.processRemoteClose(this); + break; + case LINK_REMOTE_DETACH: + amqpEventSink = (AmqpEventSink) protonEvent.getLink().getContext(); + amqpEventSink.processRemoteDetach(this); + break; + case LINK_REMOTE_OPEN: + amqpEventSink = (AmqpEventSink) protonEvent.getLink().getContext(); + amqpEventSink.processRemoteOpen(this); + break; + case LINK_FLOW: + amqpEventSink = (AmqpEventSink) protonEvent.getLink().getContext(); + amqpEventSink.processFlowUpdates(this); + break; + case DELIVERY: + amqpEventSink = (AmqpEventSink) protonEvent.getLink().getContext(); + amqpEventSink.processDeliveryUpdates(this); + break; + default: + break; + } + + protonCollector.pop(); + } + + // We have to do this to pump SASL bytes in as SASL is not event driven yet. + if (!authenticated) { + processSaslAuthentication(); + } + } + catch (Exception ex) { + LOG.warn("Caught Exception during update processing: {}", ex.getMessage(), ex); + fireClientException(ex); + } + } + + private void processSaslAuthentication() { + if (authenticated || authenticator == null) { + return; + } + + try { + if (authenticator.authenticate()) { + authenticator = null; + authenticated = true; + } + } + catch (SecurityException ex) { + failed(ex); + } + } + + private String getNextSessionId() { + return connectionId + ":" + sessionIdGenerator.incrementAndGet(); + } + + private String safeGetContainerId() { + String containerId = getContainerId(); + if (containerId == null || containerId.isEmpty()) { + containerId = UUID.randomUUID().toString(); + } + + return containerId; + } + + @Override + public String toString() { + return "AmqpConnection { " + connectionId + " }"; + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpConnectionListener.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpConnectionListener.java new file mode 100644 index 0000000000..822170ad62 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpConnectionListener.java @@ -0,0 +1,31 @@ +/** + * 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 org.apache.activemq.transport.amqp.client; + +/** + * Events points exposed by the AmqpClient object. + */ +public interface AmqpConnectionListener { + + /** + * Indicates some error has occurred during client operations. + * + * @param ex The error that triggered this event. + */ + void onException(Throwable ex); + +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpDefaultConnectionListener.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpDefaultConnectionListener.java new file mode 100644 index 0000000000..d2492e9f7f --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpDefaultConnectionListener.java @@ -0,0 +1,28 @@ +/** + * 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 org.apache.activemq.transport.amqp.client; + +/** + * Default listener implementation that stubs out all the event methods. + */ +public class AmqpDefaultConnectionListener implements AmqpConnectionListener { + + @Override + public void onException(Throwable ex) { + + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpEventSink.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpEventSink.java new file mode 100644 index 0000000000..1c511a5604 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpEventSink.java @@ -0,0 +1,69 @@ +/* + * 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 org.apache.activemq.transport.amqp.client; + +import java.io.IOException; + +/** + * Interface used by classes that want to process AMQP events sent from + * the transport layer. + */ +public interface AmqpEventSink { + + /** + * Event handler for remote peer open of this resource. + * + * @param connection the AmqpConnection instance for easier access to fire events. + * @throws IOException if an error occurs while processing the update. + */ + void processRemoteOpen(AmqpConnection connection) throws IOException; + + /** + * Event handler for remote peer detach of this resource. + * + * @param connection the AmqpConnection instance for easier access to fire events. + * @throws IOException if an error occurs while processing the update. + */ + void processRemoteDetach(AmqpConnection connection) throws IOException; + + /** + * Event handler for remote peer close of this resource. + * + * @param connection the AmqpConnection instance for easier access to fire events. + * @throws IOException if an error occurs while processing the update. + */ + void processRemoteClose(AmqpConnection connection) throws IOException; + + /** + * Called when the Proton Engine signals an Delivery related event has been triggered + * for the given endpoint. + * + * @param connection the AmqpConnection instance for easier access to fire events. + * @throws IOException if an error occurs while processing the update. + */ + void processDeliveryUpdates(AmqpConnection connection) throws IOException; + + /** + * Called when the Proton Engine signals an Flow related event has been triggered + * for the given endpoint. + * + * @param connection the AmqpConnection instance for easier access to fire events. + * @throws IOException if an error occurs while processing the update. + */ + void processFlowUpdates(AmqpConnection connection) throws IOException; + +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpJmsSelectorFilter.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpJmsSelectorFilter.java new file mode 100644 index 0000000000..adf5df6458 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpJmsSelectorFilter.java @@ -0,0 +1,48 @@ +/** + * 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 org.apache.activemq.transport.amqp.client; + +import org.apache.qpid.proton.amqp.DescribedType; + +import static org.apache.activemq.transport.amqp.AmqpSupport.JMS_SELECTOR_CODE; + +/** + * A Described Type wrapper for JMS selector values. + */ +public class AmqpJmsSelectorFilter implements DescribedType { + + private final String selector; + + public AmqpJmsSelectorFilter(String selector) { + this.selector = selector; + } + + @Override + public Object getDescriptor() { + return JMS_SELECTOR_CODE; + } + + @Override + public Object getDescribed() { + return this.selector; + } + + @Override + public String toString() { + return "AmqpJmsSelectorType{" + selector + "}"; + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpMessage.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpMessage.java new file mode 100644 index 0000000000..320d174710 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpMessage.java @@ -0,0 +1,515 @@ +/* + * 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 org.apache.activemq.transport.amqp.client; + +import java.util.HashMap; +import java.util.Map; +import java.util.NoSuchElementException; + +import org.apache.activemq.transport.amqp.client.util.UnmodifiableDelivery; +import org.apache.qpid.proton.Proton; +import org.apache.qpid.proton.amqp.Binary; +import org.apache.qpid.proton.amqp.DescribedType; +import org.apache.qpid.proton.amqp.Symbol; +import org.apache.qpid.proton.amqp.messaging.AmqpValue; +import org.apache.qpid.proton.amqp.messaging.ApplicationProperties; +import org.apache.qpid.proton.amqp.messaging.Data; +import org.apache.qpid.proton.amqp.messaging.DeliveryAnnotations; +import org.apache.qpid.proton.amqp.messaging.Header; +import org.apache.qpid.proton.amqp.messaging.MessageAnnotations; +import org.apache.qpid.proton.amqp.messaging.Properties; +import org.apache.qpid.proton.engine.Delivery; +import org.apache.qpid.proton.message.Message; + +public class AmqpMessage { + + private final AmqpReceiver receiver; + private final Message message; + private final Delivery delivery; + + private Map deliveryAnnotationsMap; + private Map messageAnnotationsMap; + private Map applicationPropertiesMap; + + /** + * Creates a new AmqpMessage that wraps the information necessary to handle + * an outgoing message. + */ + public AmqpMessage() { + receiver = null; + delivery = null; + + message = Proton.message(); + } + + /** + * Creates a new AmqpMessage that wraps the information necessary to handle + * an outgoing message. + * + * @param message the Proton message that is to be sent. + */ + public AmqpMessage(Message message) { + this(null, message, null); + } + + /** + * Creates a new AmqpMessage that wraps the information necessary to handle + * an incoming delivery. + * + * @param receiver the AmqpReceiver that received this message. + * @param message the Proton message that was received. + * @param delivery the Delivery instance that produced this message. + */ + @SuppressWarnings("unchecked") + public AmqpMessage(AmqpReceiver receiver, Message message, Delivery delivery) { + this.receiver = receiver; + this.message = message; + this.delivery = delivery; + + if (message.getMessageAnnotations() != null) { + messageAnnotationsMap = message.getMessageAnnotations().getValue(); + } + + if (message.getApplicationProperties() != null) { + applicationPropertiesMap = message.getApplicationProperties().getValue(); + } + + if (message.getDeliveryAnnotations() != null) { + deliveryAnnotationsMap = message.getDeliveryAnnotations().getValue(); + } + } + + //----- Access to interal client resources -------------------------------// + + /** + * @return the AMQP Delivery object linked to a received message. + */ + public Delivery getWrappedDelivery() { + if (delivery != null) { + return new UnmodifiableDelivery(delivery); + } + + return null; + } + + /** + * @return the AMQP Message that is wrapped by this object. + */ + public Message getWrappedMessage() { + return message; + } + + /** + * @return the AmqpReceiver that consumed this message. + */ + public AmqpReceiver getAmqpReceiver() { + return receiver; + } + + //----- Message disposition control --------------------------------------// + + /** + * Accepts the message marking it as consumed on the remote peer. + * + * @throws Exception if an error occurs during the accept. + */ + public void accept() throws Exception { + if (receiver == null) { + throw new IllegalStateException("Can't accept non-received message."); + } + + receiver.accept(delivery); + } + + /** + * Marks the message as Modified, indicating whether it failed to deliver and is not deliverable here. + * + * @param deliveryFailed indicates that the delivery failed for some reason. + * @param undeliverableHere marks the delivery as not being able to be process by link it was sent to. + * @throws Exception if an error occurs during the process. + */ + public void modified(Boolean deliveryFailed, Boolean undeliverableHere) throws Exception { + if (receiver == null) { + throw new IllegalStateException("Can't modify non-received message."); + } + + receiver.modified(delivery, deliveryFailed, undeliverableHere); + } + + /** + * Release the message, remote can redeliver it elsewhere. + * + * @throws Exception if an error occurs during the reject. + */ + public void release() throws Exception { + if (receiver == null) { + throw new IllegalStateException("Can't release non-received message."); + } + + receiver.release(delivery); + } + + //----- Convenience methods for constructing outbound messages -----------// + + /** + * Sets the MessageId property on an outbound message using the provided String + * + * @param messageId the String message ID value to set. + */ + public void setMessageId(String messageId) { + checkReadOnly(); + lazyCreateProperties(); + getWrappedMessage().setMessageId(messageId); + } + + /** + * Return the set MessageId value in String form, if there are no properties + * in the given message return null. + * + * @return the set message ID in String form or null if not set. + */ + public String getMessageId() { + if (message.getProperties() == null) { + return null; + } + + return message.getProperties().getMessageId().toString(); + } + + /** + * Return the set MessageId value in the original form, if there are no properties + * in the given message return null. + * + * @return the set message ID in its original form or null if not set. + */ + public Object getRawMessageId() { + if (message.getProperties() == null) { + return null; + } + + return message.getProperties().getMessageId(); + } + + /** + * Sets the MessageId property on an outbound message using the provided value + * + * @param messageId the message ID value to set. + */ + public void setRawMessageId(Object messageId) { + checkReadOnly(); + lazyCreateProperties(); + getWrappedMessage().setMessageId(messageId); + } + + /** + * Sets the CorrelationId property on an outbound message using the provided String + * + * @param correlationId the String Correlation ID value to set. + */ + public void setCorrelationId(String correlationId) { + checkReadOnly(); + lazyCreateProperties(); + getWrappedMessage().setCorrelationId(correlationId); + } + + /** + * Return the set CorrelationId value in String form, if there are no properties + * in the given message return null. + * + * @return the set correlation ID in String form or null if not set. + */ + public String getCorrelationId() { + if (message.getProperties() == null) { + return null; + } + + return message.getProperties().getCorrelationId().toString(); + } + + /** + * Return the set CorrelationId value in the original form, if there are no properties + * in the given message return null. + * + * @return the set message ID in its original form or null if not set. + */ + public Object getRawCorrelationId() { + if (message.getProperties() == null) { + return null; + } + + return message.getProperties().getCorrelationId(); + } + + /** + * Sets the CorrelationId property on an outbound message using the provided value + * + * @param correlationId the correlation ID value to set. + */ + public void setRawCorrelationId(Object correlationId) { + checkReadOnly(); + lazyCreateProperties(); + getWrappedMessage().setCorrelationId(correlationId); + } + + /** + * Sets the GroupId property on an outbound message using the provided String + * + * @param groupId the String Group ID value to set. + */ + public void setGroupId(String groupId) { + checkReadOnly(); + lazyCreateProperties(); + getWrappedMessage().setGroupId(groupId); + } + + /** + * Return the set GroupId value in String form, if there are no properties + * in the given message return null. + * + * @return the set GroupID in String form or null if not set. + */ + public String getGroupId() { + if (message.getProperties() == null) { + return null; + } + + return message.getProperties().getGroupId(); + } + + /** + * Sets the durable header on the outgoing message. + * + * @param durable the boolean durable value to set. + */ + public void setDurable(boolean durable) { + checkReadOnly(); + lazyCreateHeader(); + getWrappedMessage().setDurable(durable); + } + + /** + * Checks the durable value in the Message Headers to determine if + * the message was sent as a durable Message. + * + * @return true if the message is marked as being durable. + */ + public boolean isDurable() { + if (message.getHeader() == null) { + return false; + } + + return message.getHeader().getDurable(); + } + + /** + * Sets a given application property on an outbound message. + * + * @param key the name to assign the new property. + * @param value the value to set for the named property. + */ + public void setApplicationProperty(String key, Object value) { + checkReadOnly(); + lazyCreateApplicationProperties(); + applicationPropertiesMap.put(key, value); + } + + /** + * Gets the application property that is mapped to the given name or null + * if no property has been set with that name. + * + * @param key the name used to lookup the property in the application properties. + * @return the propety value or null if not set. + */ + public Object getApplicationProperty(String key) { + if (applicationPropertiesMap == null) { + return null; + } + + return applicationPropertiesMap.get(key); + } + + /** + * Perform a proper annotation set on the AMQP Message based on a Symbol key and + * the target value to append to the current annotations. + * + * @param key The name of the Symbol whose value is being set. + * @param value The new value to set in the annotations of this message. + */ + public void setMessageAnnotation(String key, Object value) { + checkReadOnly(); + lazyCreateMessageAnnotations(); + messageAnnotationsMap.put(Symbol.valueOf(key), value); + } + + /** + * Given a message annotation name, lookup and return the value associated with + * that annotation name. If the message annotations have not been created yet + * then this method will always return null. + * + * @param key the Symbol name that should be looked up in the message annotations. + * @return the value of the annotation if it exists, or null if not set or not accessible. + */ + public Object getMessageAnnotation(String key) { + if (messageAnnotationsMap == null) { + return null; + } + + return messageAnnotationsMap.get(Symbol.valueOf(key)); + } + + /** + * Perform a proper delivery annotation set on the AMQP Message based on a Symbol + * key and the target value to append to the current delivery annotations. + * + * @param key The name of the Symbol whose value is being set. + * @param value The new value to set in the delivery annotations of this message. + */ + public void setDeliveryAnnotation(String key, Object value) { + checkReadOnly(); + lazyCreateDeliveryAnnotations(); + deliveryAnnotationsMap.put(Symbol.valueOf(key), value); + } + + /** + * Given a message annotation name, lookup and return the value associated with + * that annotation name. If the message annotations have not been created yet + * then this method will always return null. + * + * @param key the Symbol name that should be looked up in the message annotations. + * @return the value of the annotation if it exists, or null if not set or not accessible. + */ + public Object getDeliveryAnnotation(String key) { + if (deliveryAnnotationsMap == null) { + return null; + } + + return deliveryAnnotationsMap.get(Symbol.valueOf(key)); + } + + //----- Methods for manipulating the Message body ------------------------// + + /** + * Sets a String value into the body of an outgoing Message, throws + * an exception if this is an incoming message instance. + * + * @param value the String value to store in the Message body. + * @throws IllegalStateException if the message is read only. + */ + public void setText(String value) throws IllegalStateException { + checkReadOnly(); + AmqpValue body = new AmqpValue(value); + getWrappedMessage().setBody(body); + } + + /** + * Sets a byte array value into the body of an outgoing Message, throws + * an exception if this is an incoming message instance. + * + * @param bytes the byte array value to store in the Message body. + * @throws IllegalStateException if the message is read only. + */ + public void setBytes(byte[] bytes) throws IllegalStateException { + checkReadOnly(); + Data body = new Data(new Binary(bytes)); + getWrappedMessage().setBody(body); + } + + /** + * Sets a byte array value into the body of an outgoing Message, throws + * an exception if this is an incoming message instance. + * + * @param described the byte array value to store in the Message body. + * @throws IllegalStateException if the message is read only. + */ + public void setDescribedType(DescribedType described) throws IllegalStateException { + checkReadOnly(); + AmqpValue body = new AmqpValue(described); + getWrappedMessage().setBody(body); + } + + /** + * Attempts to retrieve the message body as an DescribedType instance. + * + * @return an DescribedType instance if one is stored in the message body. + * @throws NoSuchElementException if the body does not contain a DescribedType. + */ + public DescribedType getDescribedType() throws NoSuchElementException { + DescribedType result = null; + + if (getWrappedMessage().getBody() == null) { + return null; + } + else { + if (getWrappedMessage().getBody() instanceof AmqpValue) { + AmqpValue value = (AmqpValue) getWrappedMessage().getBody(); + + if (value.getValue() == null) { + result = null; + } + else if (value.getValue() instanceof DescribedType) { + result = (DescribedType) value.getValue(); + } + else { + throw new NoSuchElementException("Message does not contain a DescribedType body"); + } + } + } + + return result; + } + + //----- Internal implementation ------------------------------------------// + + private void checkReadOnly() throws IllegalStateException { + if (delivery != null) { + throw new IllegalStateException("Message is read only."); + } + } + + private void lazyCreateMessageAnnotations() { + if (messageAnnotationsMap == null) { + messageAnnotationsMap = new HashMap<>(); + message.setMessageAnnotations(new MessageAnnotations(messageAnnotationsMap)); + } + } + + private void lazyCreateDeliveryAnnotations() { + if (deliveryAnnotationsMap == null) { + deliveryAnnotationsMap = new HashMap<>(); + message.setDeliveryAnnotations(new DeliveryAnnotations(deliveryAnnotationsMap)); + } + } + + private void lazyCreateApplicationProperties() { + if (applicationPropertiesMap == null) { + applicationPropertiesMap = new HashMap<>(); + message.setApplicationProperties(new ApplicationProperties(applicationPropertiesMap)); + } + } + + private void lazyCreateHeader() { + if (message.getHeader() == null) { + message.setHeader(new Header()); + } + } + + private void lazyCreateProperties() { + if (message.getProperties() == null) { + message.setProperties(new Properties()); + } + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpNoLocalFilter.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpNoLocalFilter.java new file mode 100644 index 0000000000..2e36e84685 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpNoLocalFilter.java @@ -0,0 +1,45 @@ +/** + * 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 org.apache.activemq.transport.amqp.client; + +import org.apache.qpid.proton.amqp.DescribedType; + +import static org.apache.activemq.transport.amqp.AmqpSupport.NO_LOCAL_CODE; + +/** + * A Described Type wrapper for JMS no local option for MessageConsumer. + */ +public class AmqpNoLocalFilter implements DescribedType { + + public static final AmqpNoLocalFilter NO_LOCAL = new AmqpNoLocalFilter(); + + private final String noLocal; + + public AmqpNoLocalFilter() { + this.noLocal = "NoLocalFilter{}"; + } + + @Override + public Object getDescriptor() { + return NO_LOCAL_CODE; + } + + @Override + public Object getDescribed() { + return this.noLocal; + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpReceiver.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpReceiver.java new file mode 100644 index 0000000000..9f3bff20fb --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpReceiver.java @@ -0,0 +1,946 @@ +/** + * 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 org.apache.activemq.transport.amqp.client; + +import javax.jms.InvalidDestinationException; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingDeque; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.apache.activemq.transport.amqp.client.util.AsyncResult; +import org.apache.activemq.transport.amqp.client.util.ClientFuture; +import org.apache.activemq.transport.amqp.client.util.IOExceptionSupport; +import org.apache.activemq.transport.amqp.client.util.UnmodifiableReceiver; +import org.apache.qpid.jms.JmsOperationTimedOutException; +import org.apache.qpid.proton.amqp.Binary; +import org.apache.qpid.proton.amqp.DescribedType; +import org.apache.qpid.proton.amqp.Symbol; +import org.apache.qpid.proton.amqp.messaging.Accepted; +import org.apache.qpid.proton.amqp.messaging.Modified; +import org.apache.qpid.proton.amqp.messaging.Rejected; +import org.apache.qpid.proton.amqp.messaging.Released; +import org.apache.qpid.proton.amqp.messaging.Source; +import org.apache.qpid.proton.amqp.messaging.Target; +import org.apache.qpid.proton.amqp.messaging.TerminusDurability; +import org.apache.qpid.proton.amqp.messaging.TerminusExpiryPolicy; +import org.apache.qpid.proton.amqp.transaction.TransactionalState; +import org.apache.qpid.proton.amqp.transport.ReceiverSettleMode; +import org.apache.qpid.proton.amqp.transport.SenderSettleMode; +import org.apache.qpid.proton.engine.Delivery; +import org.apache.qpid.proton.engine.Receiver; +import org.apache.qpid.proton.message.Message; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static org.apache.activemq.transport.amqp.AmqpSupport.COPY; +import static org.apache.activemq.transport.amqp.AmqpSupport.JMS_SELECTOR_NAME; +import static org.apache.activemq.transport.amqp.AmqpSupport.NO_LOCAL_NAME; + +/** + * Receiver class that manages a Proton receiver endpoint. + */ +public class AmqpReceiver extends AmqpAbstractResource { + + private static final Logger LOG = LoggerFactory.getLogger(AmqpReceiver.class); + + private final AtomicBoolean closed = new AtomicBoolean(); + private final BlockingQueue prefetch = new LinkedBlockingDeque<>(); + + private final AmqpSession session; + private final String address; + private final String receiverId; + private final Source userSpecifiedSource; + + private String subscriptionName; + private String selector; + private boolean presettle; + private boolean noLocal; + + private AsyncResult pullRequest; + private AsyncResult stopRequest; + + /** + * Create a new receiver instance. + * + * @param session The parent session that created the receiver. + * @param address The address that this receiver should listen on. + * @param receiverId The unique ID assigned to this receiver. + */ + public AmqpReceiver(AmqpSession session, String address, String receiverId) { + + if (address != null && address.isEmpty()) { + throw new IllegalArgumentException("Address cannot be empty."); + } + + this.userSpecifiedSource = null; + this.session = session; + this.address = address; + this.receiverId = receiverId; + } + + /** + * Create a new receiver instance. + * + * @param session The parent session that created the receiver. + * @param source The Source instance to use instead of creating and configuring one. + * @param receiverId The unique ID assigned to this receiver. + */ + public AmqpReceiver(AmqpSession session, Source source, String receiverId) { + + if (source == null) { + throw new IllegalArgumentException("User specified Source cannot be null"); + } + + this.session = session; + this.userSpecifiedSource = source; + this.address = source.getAddress(); + this.receiverId = receiverId; + } + + /** + * Close the receiver, a closed receiver will throw exceptions if any further send + * calls are made. + * + * @throws IOException if an error occurs while closing the receiver. + */ + public void close() throws IOException { + if (closed.compareAndSet(false, true)) { + final ClientFuture request = new ClientFuture(); + session.getScheduler().execute(new Runnable() { + + @Override + public void run() { + checkClosed(); + close(request); + session.pumpToProtonTransport(request); + } + }); + + request.sync(); + } + } + + /** + * Detach the receiver, a closed receiver will throw exceptions if any further send + * calls are made. + * + * @throws IOException if an error occurs while closing the receiver. + */ + public void detach() throws IOException { + if (closed.compareAndSet(false, true)) { + final ClientFuture request = new ClientFuture(); + session.getScheduler().execute(new Runnable() { + + @Override + public void run() { + checkClosed(); + detach(request); + session.pumpToProtonTransport(request); + } + }); + + request.sync(); + } + } + + /** + * @return this session's parent AmqpSession. + */ + public AmqpSession getSession() { + return session; + } + + /** + * @return the address that this receiver has been configured to listen on. + */ + public String getAddress() { + return address; + } + + /** + * Attempts to wait on a message to be delivered to this receiver. The receive + * call will wait indefinitely for a message to be delivered. + * + * @return a newly received message sent to this receiver. + * @throws Exception if an error occurs during the receive attempt. + */ + public AmqpMessage receive() throws Exception { + checkClosed(); + return prefetch.take(); + } + + /** + * Attempts to receive a message sent to this receiver, waiting for the given + * timeout value before giving up and returning null. + * + * @param timeout the time to wait for a new message to arrive. + * @param unit the unit of time that the timeout value represents. + * @return a newly received message or null if the time to wait period expires. + * @throws Exception if an error occurs during the receive attempt. + */ + public AmqpMessage receive(long timeout, TimeUnit unit) throws Exception { + checkClosed(); + return prefetch.poll(timeout, unit); + } + + /** + * If a message is already available in this receiver's prefetch buffer then + * it is returned immediately otherwise this methods return null without waiting. + * + * @return a newly received message or null if there is no currently available message. + * @throws Exception if an error occurs during the receive attempt. + */ + public AmqpMessage receiveNoWait() throws Exception { + checkClosed(); + return prefetch.poll(); + } + + /** + * Request a remote peer send a Message to this client waiting until one arrives. + * + * @return the pulled AmqpMessage or null if none was pulled from the remote. + * @throws IOException if an error occurs + */ + public AmqpMessage pull() throws IOException { + return pull(-1, TimeUnit.MILLISECONDS); + } + + /** + * Request a remote peer send a Message to this client using an immediate drain request. + * + * @return the pulled AmqpMessage or null if none was pulled from the remote. + * @throws IOException if an error occurs + */ + public AmqpMessage pullImmediate() throws IOException { + return pull(0, TimeUnit.MILLISECONDS); + } + + /** + * Request a remote peer send a Message to this client. + * + * {@literal timeout < 0} then it should remain open until a message is received. + * {@literal timeout = 0} then it returns a message or null if none available + * {@literal timeout > 0} then it should remain open for timeout amount of time. + * + * The timeout value when positive is given in milliseconds. + * + * @param timeout the amount of time to tell the remote peer to keep this pull request valid. + * @param unit the unit of measure that the timeout represents. + * @return the pulled AmqpMessage or null if none was pulled from the remote. + * @throws IOException if an error occurs + */ + public AmqpMessage pull(final long timeout, final TimeUnit unit) throws IOException { + checkClosed(); + final ClientFuture request = new ClientFuture(); + session.getScheduler().execute(new Runnable() { + + @Override + public void run() { + checkClosed(); + + long timeoutMills = unit.toMillis(timeout); + + try { + LOG.trace("Pull on Receiver {} with timeout = {}", getSubscriptionName(), timeoutMills); + if (timeoutMills < 0) { + // Wait until message arrives. Just give credit if needed. + if (getEndpoint().getCredit() == 0) { + LOG.trace("Receiver {} granting 1 additional credit for pull.", getSubscriptionName()); + getEndpoint().flow(1); + } + + // Await the message arrival + pullRequest = request; + } + else if (timeoutMills == 0) { + // If we have no credit then we need to issue some so that we can + // try to fulfill the request, then drain down what is there to + // ensure we consume what is available and remove all credit. + if (getEndpoint().getCredit() == 0) { + LOG.trace("Receiver {} granting 1 additional credit for pull.", getSubscriptionName()); + getEndpoint().flow(1); + } + + // Drain immediately and wait for the message(s) to arrive, + // or a flow indicating removal of the remaining credit. + stop(request); + } + else if (timeoutMills > 0) { + // If we have no credit then we need to issue some so that we can + // try to fulfill the request, then drain down what is there to + // ensure we consume what is available and remove all credit. + if (getEndpoint().getCredit() == 0) { + LOG.trace("Receiver {} granting 1 additional credit for pull.", getSubscriptionName()); + getEndpoint().flow(1); + } + + // Wait for the timeout for the message(s) to arrive, then drain if required + // and wait for remaining message(s) to arrive or a flow indicating + // removal of the remaining credit. + stopOnSchedule(timeoutMills, request); + } + + session.pumpToProtonTransport(request); + } + catch (Exception e) { + request.onFailure(e); + } + } + }); + + request.sync(); + + return prefetch.poll(); + } + + /** + * Controls the amount of credit given to the receiver link. + * + * @param credit the amount of credit to grant. + * @throws IOException if an error occurs while sending the flow. + */ + public void flow(final int credit) throws IOException { + checkClosed(); + final ClientFuture request = new ClientFuture(); + session.getScheduler().execute(new Runnable() { + + @Override + public void run() { + checkClosed(); + try { + getEndpoint().flow(credit); + session.pumpToProtonTransport(request); + request.onSuccess(); + } + catch (Exception e) { + request.onFailure(e); + } + } + }); + + request.sync(); + } + + /** + * Attempts to drain a given amount of credit from the link. + * + * @param credit the amount of credit to drain. + * @throws IOException if an error occurs while sending the drain. + */ + public void drain(final int credit) throws IOException { + checkClosed(); + final ClientFuture request = new ClientFuture(); + session.getScheduler().execute(new Runnable() { + + @Override + public void run() { + checkClosed(); + try { + getEndpoint().drain(credit); + session.pumpToProtonTransport(request); + request.onSuccess(); + } + catch (Exception e) { + request.onFailure(e); + } + } + }); + + request.sync(); + } + + /** + * Stops the receiver, using all link credit and waiting for in-flight messages to arrive. + * + * @throws IOException if an error occurs while sending the drain. + */ + public void stop() throws IOException { + checkClosed(); + final ClientFuture request = new ClientFuture(); + session.getScheduler().execute(new Runnable() { + + @Override + public void run() { + checkClosed(); + try { + stop(request); + session.pumpToProtonTransport(request); + } + catch (Exception e) { + request.onFailure(e); + } + } + }); + + request.sync(); + } + + /** + * Accepts a message that was dispatched under the given Delivery instance. + * + * @param delivery the Delivery instance to accept. + * @throws IOException if an error occurs while sending the accept. + */ + public void accept(final Delivery delivery) throws IOException { + checkClosed(); + + if (delivery == null) { + throw new IllegalArgumentException("Delivery to accept cannot be null"); + } + + final ClientFuture request = new ClientFuture(); + session.getScheduler().execute(new Runnable() { + + @Override + public void run() { + checkClosed(); + try { + if (!delivery.isSettled()) { + if (session.isInTransaction()) { + Binary txnId = session.getTransactionId().getRemoteTxId(); + if (txnId != null) { + TransactionalState txState = new TransactionalState(); + txState.setOutcome(Accepted.getInstance()); + txState.setTxnId(txnId); + delivery.disposition(txState); + delivery.settle(); + session.getTransactionContext().registerTxConsumer(AmqpReceiver.this); + } + } + else { + delivery.disposition(Accepted.getInstance()); + delivery.settle(); + } + } + session.pumpToProtonTransport(request); + request.onSuccess(); + } + catch (Exception e) { + request.onFailure(e); + } + } + }); + + request.sync(); + } + + /** + * Mark a message that was dispatched under the given Delivery instance as Modified. + * + * @param delivery the Delivery instance to mark modified. + * @param deliveryFailed indicates that the delivery failed for some reason. + * @param undeliverableHere marks the delivery as not being able to be process by link it was sent to. + * @throws IOException if an error occurs while sending the reject. + */ + public void modified(final Delivery delivery, + final Boolean deliveryFailed, + final Boolean undeliverableHere) throws IOException { + checkClosed(); + + if (delivery == null) { + throw new IllegalArgumentException("Delivery to reject cannot be null"); + } + + final ClientFuture request = new ClientFuture(); + session.getScheduler().execute(new Runnable() { + + @Override + public void run() { + checkClosed(); + try { + if (!delivery.isSettled()) { + Modified disposition = new Modified(); + disposition.setUndeliverableHere(undeliverableHere); + disposition.setDeliveryFailed(deliveryFailed); + delivery.disposition(disposition); + delivery.settle(); + session.pumpToProtonTransport(request); + } + request.onSuccess(); + } + catch (Exception e) { + request.onFailure(e); + } + } + }); + + request.sync(); + } + + /** + * Release a message that was dispatched under the given Delivery instance. + * + * @param delivery the Delivery instance to release. + * @throws IOException if an error occurs while sending the release. + */ + public void release(final Delivery delivery) throws IOException { + checkClosed(); + + if (delivery == null) { + throw new IllegalArgumentException("Delivery to release cannot be null"); + } + + final ClientFuture request = new ClientFuture(); + session.getScheduler().execute(new Runnable() { + + @Override + public void run() { + checkClosed(); + try { + if (!delivery.isSettled()) { + delivery.disposition(Released.getInstance()); + delivery.settle(); + session.pumpToProtonTransport(request); + } + request.onSuccess(); + } + catch (Exception e) { + request.onFailure(e); + } + } + }); + + request.sync(); + } + + /** + * @return an unmodifiable view of the underlying Receiver instance. + */ + public Receiver getReceiver() { + return new UnmodifiableReceiver(getEndpoint()); + } + + //----- Receiver configuration properties --------------------------------// + + public boolean isPresettle() { + return presettle; + } + + public void setPresettle(boolean presettle) { + this.presettle = presettle; + } + + public boolean isDurable() { + return subscriptionName != null; + } + + public String getSubscriptionName() { + return subscriptionName; + } + + public void setSubscriptionName(String subscriptionName) { + this.subscriptionName = subscriptionName; + } + + public String getSelector() { + return selector; + } + + public void setSelector(String selector) { + this.selector = selector; + } + + public boolean isNoLocal() { + return noLocal; + } + + public void setNoLocal(boolean noLocal) { + this.noLocal = noLocal; + } + + public long getDrainTimeout() { + return session.getConnection().getDrainTimeout(); + } + + //----- Internal implementation ------------------------------------------// + + @Override + protected void doOpen() { + + Source source = userSpecifiedSource; + Target target = new Target(); + + if (source == null && address != null) { + source = new Source(); + source.setAddress(address); + configureSource(source); + } + + String receiverName = receiverId + ":" + address; + + if (getSubscriptionName() != null && !getSubscriptionName().isEmpty()) { + // In the case of Durable Topic Subscriptions the client must use the same + // receiver name which is derived from the subscription name property. + receiverName = getSubscriptionName(); + } + + Receiver receiver = session.getEndpoint().receiver(receiverName); + receiver.setSource(source); + receiver.setTarget(target); + if (isPresettle()) { + receiver.setSenderSettleMode(SenderSettleMode.SETTLED); + } + else { + receiver.setSenderSettleMode(SenderSettleMode.UNSETTLED); + } + receiver.setReceiverSettleMode(ReceiverSettleMode.FIRST); + + setEndpoint(receiver); + + super.doOpen(); + } + + @Override + protected void doOpenCompletion() { + // Verify the attach response contained a non-null Source + org.apache.qpid.proton.amqp.transport.Source s = getEndpoint().getRemoteSource(); + if (s != null) { + super.doOpenCompletion(); + } + else { + // No link terminus was created, the peer will now detach/close us. + } + } + + @Override + protected void doClose() { + getEndpoint().close(); + } + + @Override + protected void doDetach() { + getEndpoint().detach(); + } + + @Override + protected Exception getOpenAbortException() { + // Verify the attach response contained a non-null Source + org.apache.qpid.proton.amqp.transport.Source s = getEndpoint().getRemoteSource(); + if (s != null) { + return super.getOpenAbortException(); + } + else { + // No link terminus was created, the peer has detach/closed us, create IDE. + return new InvalidDestinationException("Link creation was refused"); + } + } + + @Override + protected void doOpenInspection() { + try { + getStateInspector().inspectOpenedResource(getReceiver()); + } + catch (Throwable error) { + getStateInspector().markAsInvalid(error.getMessage()); + } + } + + @Override + protected void doClosedInspection() { + try { + getStateInspector().inspectClosedResource(getReceiver()); + } + catch (Throwable error) { + getStateInspector().markAsInvalid(error.getMessage()); + } + } + + @Override + protected void doDetachedInspection() { + try { + getStateInspector().inspectDetachedResource(getReceiver()); + } + catch (Throwable error) { + getStateInspector().markAsInvalid(error.getMessage()); + } + } + + protected void configureSource(Source source) { + Map filters = new HashMap<>(); + Symbol[] outcomes = new Symbol[]{Accepted.DESCRIPTOR_SYMBOL, Rejected.DESCRIPTOR_SYMBOL, Released.DESCRIPTOR_SYMBOL, Modified.DESCRIPTOR_SYMBOL}; + + if (getSubscriptionName() != null && !getSubscriptionName().isEmpty()) { + source.setExpiryPolicy(TerminusExpiryPolicy.NEVER); + source.setDurable(TerminusDurability.UNSETTLED_STATE); + source.setDistributionMode(COPY); + } + else { + source.setDurable(TerminusDurability.NONE); + source.setExpiryPolicy(TerminusExpiryPolicy.LINK_DETACH); + } + + source.setOutcomes(outcomes); + + Modified modified = new Modified(); + modified.setDeliveryFailed(true); + modified.setUndeliverableHere(false); + + source.setDefaultOutcome(modified); + + if (isNoLocal()) { + filters.put(NO_LOCAL_NAME, AmqpNoLocalFilter.NO_LOCAL); + } + + if (getSelector() != null && !getSelector().trim().equals("")) { + filters.put(JMS_SELECTOR_NAME, new AmqpJmsSelectorFilter(getSelector())); + } + + if (!filters.isEmpty()) { + source.setFilter(filters); + } + } + + @Override + public void processDeliveryUpdates(AmqpConnection connection) throws IOException { + Delivery incoming = null; + do { + incoming = getEndpoint().current(); + if (incoming != null) { + if (incoming.isReadable() && !incoming.isPartial()) { + LOG.trace("{} has incoming Message(s).", this); + try { + processDelivery(incoming); + } + catch (Exception e) { + throw IOExceptionSupport.create(e); + } + getEndpoint().advance(); + } + else { + LOG.trace("{} has a partial incoming Message(s), deferring.", this); + incoming = null; + } + } + else { + // We have exhausted the locally queued messages on this link. + // Check if we tried to stop and have now run out of credit. + if (getEndpoint().getRemoteCredit() <= 0) { + if (stopRequest != null) { + stopRequest.onSuccess(); + stopRequest = null; + } + } + } + } while (incoming != null); + + super.processDeliveryUpdates(connection); + } + + private void processDelivery(Delivery incoming) throws Exception { + Message message = null; + try { + message = decodeIncomingMessage(incoming); + } + catch (Exception e) { + LOG.warn("Error on transform: {}", e.getMessage()); + deliveryFailed(incoming, true); + return; + } + + AmqpMessage amqpMessage = new AmqpMessage(this, message, incoming); + // Store reference to envelope in delivery context for recovery + incoming.setContext(amqpMessage); + prefetch.add(amqpMessage); + + // We processed a message, signal completion + // of a message pull request if there is one. + if (pullRequest != null) { + pullRequest.onSuccess(); + pullRequest = null; + } + } + + @Override + public void processFlowUpdates(AmqpConnection connection) throws IOException { + if (pullRequest != null || stopRequest != null) { + Receiver receiver = getEndpoint(); + if (receiver.getRemoteCredit() <= 0 && receiver.getQueued() == 0) { + if (pullRequest != null) { + pullRequest.onSuccess(); + pullRequest = null; + } + + if (stopRequest != null) { + stopRequest.onSuccess(); + stopRequest = null; + } + } + } + + LOG.trace("Consumer {} flow updated, remote credit = {}", getSubscriptionName(), getEndpoint().getRemoteCredit()); + + super.processFlowUpdates(connection); + } + + protected Message decodeIncomingMessage(Delivery incoming) { + int count; + + byte[] chunk = new byte[2048]; + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + + while ((count = getEndpoint().recv(chunk, 0, chunk.length)) > 0) { + stream.write(chunk, 0, count); + } + + byte[] messageBytes = stream.toByteArray(); + + try { + Message protonMessage = Message.Factory.create(); + protonMessage.decode(messageBytes, 0, messageBytes.length); + return protonMessage; + } + finally { + try { + stream.close(); + } + catch (IOException e) { + } + } + } + + protected void deliveryFailed(Delivery incoming, boolean expandCredit) { + Modified disposition = new Modified(); + disposition.setUndeliverableHere(true); + disposition.setDeliveryFailed(true); + incoming.disposition(disposition); + incoming.settle(); + if (expandCredit) { + getEndpoint().flow(1); + } + } + + private void stop(final AsyncResult request) { + Receiver receiver = getEndpoint(); + if (receiver.getRemoteCredit() <= 0) { + if (receiver.getQueued() == 0) { + // We have no remote credit and all the deliveries have been processed. + request.onSuccess(); + } + else { + // There are still deliveries to process, wait for them to be. + stopRequest = request; + } + } + else { + // TODO: We don't actually want the additional messages that could be sent while + // draining. We could explicitly reduce credit first, or possibly use 'echo' instead + // of drain if it was supported. We would first need to understand what happens + // if we reduce credit below the number of messages already in-flight before + // the peer sees the update. + stopRequest = request; + receiver.drain(0); + + if (getDrainTimeout() > 0) { + // If the remote doesn't respond we will close the consumer and break any + // blocked receive or stop calls that are waiting. + final ScheduledFuture future = getSession().getScheduler().schedule(new Runnable() { + @Override + public void run() { + LOG.trace("Consumer {} drain request timed out", this); + Exception cause = new JmsOperationTimedOutException("Remote did not respond to a drain request in time"); + locallyClosed(session.getConnection(), cause); + stopRequest.onFailure(cause); + session.pumpToProtonTransport(stopRequest); + } + }, getDrainTimeout(), TimeUnit.MILLISECONDS); + + stopRequest = new ScheduledRequest(future, stopRequest); + } + } + } + + private void stopOnSchedule(long timeout, final AsyncResult request) { + LOG.trace("Receiver {} scheduling stop", this); + // We need to drain the credit if no message(s) arrive to use it. + final ScheduledFuture future = getSession().getScheduler().schedule(new Runnable() { + @Override + public void run() { + LOG.trace("Receiver {} running scheduled stop", this); + if (getEndpoint().getRemoteCredit() != 0) { + stop(request); + session.pumpToProtonTransport(request); + } + } + }, timeout, TimeUnit.MILLISECONDS); + + stopRequest = new ScheduledRequest(future, request); + } + + @Override + public String toString() { + return getClass().getSimpleName() + "{ address = " + address + "}"; + } + + private void checkClosed() { + if (isClosed()) { + throw new IllegalStateException("Receiver is already closed"); + } + } + + //----- Internal Transaction state callbacks -----------------------------// + + void preCommit() { + } + + void preRollback() { + } + + void postCommit() { + } + + void postRollback() { + } + + //----- Inner classes used in message pull operations --------------------// + + protected static final class ScheduledRequest implements AsyncResult { + + private final ScheduledFuture sheduledTask; + private final AsyncResult origRequest; + + public ScheduledRequest(ScheduledFuture completionTask, AsyncResult origRequest) { + this.sheduledTask = completionTask; + this.origRequest = origRequest; + } + + @Override + public void onFailure(Throwable cause) { + sheduledTask.cancel(false); + origRequest.onFailure(cause); + } + + @Override + public void onSuccess() { + boolean cancelled = sheduledTask.cancel(false); + if (cancelled) { + // Signal completion. Otherwise wait for the scheduled task to do it. + origRequest.onSuccess(); + } + } + + @Override + public boolean isComplete() { + return origRequest.isComplete(); + } + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpRedirectedException.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpRedirectedException.java new file mode 100644 index 0000000000..0c9bb817f5 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpRedirectedException.java @@ -0,0 +1,61 @@ +/** + * 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 org.apache.activemq.transport.amqp.client; + +import java.io.IOException; + +/** + * {@link IOException} derivative that defines that the remote peer has requested that this + * connection be redirected to some alternative peer. + */ +public class AmqpRedirectedException extends IOException { + + private static final long serialVersionUID = 5872211116061710369L; + + private final String hostname; + private final String networkHost; + private final int port; + + public AmqpRedirectedException(String reason, String hostname, String networkHost, int port) { + super(reason); + + this.hostname = hostname; + this.networkHost = networkHost; + this.port = port; + } + + /** + * @return the host name of the container being redirected to. + */ + public String getHostname() { + return hostname; + } + + /** + * @return the DNS host name or IP address of the peer this connection is being redirected to. + */ + public String getNetworkHost() { + return networkHost; + } + + /** + * @return the port number on the peer this connection is being redirected to. + */ + public int getPort() { + return port; + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpResource.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpResource.java new file mode 100644 index 0000000000..bd66659c7f --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpResource.java @@ -0,0 +1,108 @@ +/* + * 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 org.apache.activemq.transport.amqp.client; + +import org.apache.activemq.transport.amqp.client.util.AsyncResult; + +/** + * AmqpResource specification. + * + * All AMQP types should implement this interface to allow for control of state + * and configuration details. + */ +public interface AmqpResource extends AmqpEventSink { + + /** + * Perform all the work needed to open this resource and store the request + * until such time as the remote peer indicates the resource has become active. + * + * @param request The initiating request that triggered this open call. + */ + void open(AsyncResult request); + + /** + * @return if the resource has moved to the opened state on the remote. + */ + boolean isOpen(); + + /** + * Called to indicate that this resource is now remotely opened. Once opened a + * resource can start accepting incoming requests. + */ + void opened(); + + /** + * Perform all work needed to close this resource and store the request + * until such time as the remote peer indicates the resource has been closed. + * + * @param request The initiating request that triggered this close call. + */ + void close(AsyncResult request); + + /** + * Perform all work needed to detach this resource and store the request + * until such time as the remote peer indicates the resource has been detached. + * + * @param request The initiating request that triggered this detach call. + */ + void detach(AsyncResult request); + + /** + * @return if the resource has moved to the closed state on the remote. + */ + boolean isClosed(); + + /** + * Called to indicate that this resource is now remotely closed. Once closed a + * resource can not accept any incoming requests. + */ + void closed(); + + /** + * Sets the failed state for this Resource and triggers a failure signal for + * any pending ProduverRequest. + */ + void failed(); + + /** + * Called to indicate that the remote end has become closed but the resource + * was not awaiting a close. This could happen during an open request where + * the remote does not set an error condition or during normal operation. + * + * @param connection The connection that owns this resource. + */ + void remotelyClosed(AmqpConnection connection); + + /** + * Called to indicate that the local end has become closed but the resource + * was not awaiting a close. This could happen during an open request where + * the remote does not set an error condition or during normal operation. + * + * @param connection The connection that owns this resource. + * @param error The error that triggered the local close of this resource. + */ + void locallyClosed(AmqpConnection connection, Exception error); + + /** + * Sets the failed state for this Resource and triggers a failure signal for + * any pending ProduverRequest. + * + * @param cause The Exception that triggered the failure. + */ + void failed(Exception cause); + +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpSender.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpSender.java new file mode 100644 index 0000000000..404b943187 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpSender.java @@ -0,0 +1,452 @@ +/** + * 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 org.apache.activemq.transport.amqp.client; + +import javax.jms.InvalidDestinationException; +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.apache.activemq.transport.amqp.client.util.AsyncResult; +import org.apache.activemq.transport.amqp.client.util.ClientFuture; +import org.apache.activemq.transport.amqp.client.util.UnmodifiableSender; +import org.apache.qpid.proton.amqp.Binary; +import org.apache.qpid.proton.amqp.Symbol; +import org.apache.qpid.proton.amqp.messaging.Accepted; +import org.apache.qpid.proton.amqp.messaging.Modified; +import org.apache.qpid.proton.amqp.messaging.Outcome; +import org.apache.qpid.proton.amqp.messaging.Rejected; +import org.apache.qpid.proton.amqp.messaging.Released; +import org.apache.qpid.proton.amqp.messaging.Source; +import org.apache.qpid.proton.amqp.messaging.Target; +import org.apache.qpid.proton.amqp.transaction.TransactionalState; +import org.apache.qpid.proton.amqp.transport.DeliveryState; +import org.apache.qpid.proton.amqp.transport.ErrorCondition; +import org.apache.qpid.proton.amqp.transport.ReceiverSettleMode; +import org.apache.qpid.proton.amqp.transport.SenderSettleMode; +import org.apache.qpid.proton.engine.Delivery; +import org.apache.qpid.proton.engine.Sender; +import org.apache.qpid.proton.message.Message; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Sender class that manages a Proton sender endpoint. + */ +public class AmqpSender extends AmqpAbstractResource { + + private static final Logger LOG = LoggerFactory.getLogger(AmqpSender.class); + private static final byte[] EMPTY_BYTE_ARRAY = new byte[]{}; + + public static final long DEFAULT_SEND_TIMEOUT = 15000; + + private final AmqpTransferTagGenerator tagGenerator = new AmqpTransferTagGenerator(true); + private final AtomicBoolean closed = new AtomicBoolean(); + + private final AmqpSession session; + private final String address; + private final String senderId; + private final Target userSpecifiedTarget; + + private boolean presettle; + private long sendTimeout = DEFAULT_SEND_TIMEOUT; + + private final Set pending = new LinkedHashSet<>(); + private byte[] encodeBuffer = new byte[1024 * 8]; + + /** + * Create a new sender instance. + * + * @param session The parent session that created the session. + * @param address The address that this sender produces to. + * @param senderId The unique ID assigned to this sender. + */ + public AmqpSender(AmqpSession session, String address, String senderId) { + + if (address != null && address.isEmpty()) { + throw new IllegalArgumentException("Address cannot be empty."); + } + + this.session = session; + this.address = address; + this.senderId = senderId; + this.userSpecifiedTarget = null; + } + + /** + * Create a new sender instance using the given Target when creating the link. + * + * @param session The parent session that created the session. + * @param address The address that this sender produces to. + * @param senderId The unique ID assigned to this sender. + */ + public AmqpSender(AmqpSession session, Target target, String senderId) { + + if (target == null) { + throw new IllegalArgumentException("User specified Target cannot be null"); + } + + this.session = session; + this.userSpecifiedTarget = target; + this.address = target.getAddress(); + this.senderId = senderId; + } + + /** + * Sends the given message to this senders assigned address. + * + * @param message the message to send. + * @throws IOException if an error occurs during the send. + */ + public void send(final AmqpMessage message) throws IOException { + checkClosed(); + final ClientFuture sendRequest = new ClientFuture(); + + session.getScheduler().execute(new Runnable() { + + @Override + public void run() { + try { + doSend(message, sendRequest); + session.pumpToProtonTransport(sendRequest); + } + catch (Exception e) { + sendRequest.onFailure(e); + session.getConnection().fireClientException(e); + } + } + }); + + if (sendTimeout <= 0) { + sendRequest.sync(); + } + else { + sendRequest.sync(sendTimeout, TimeUnit.MILLISECONDS); + } + } + + /** + * Close the sender, a closed sender will throw exceptions if any further send + * calls are made. + * + * @throws IOException if an error occurs while closing the sender. + */ + public void close() throws IOException { + if (closed.compareAndSet(false, true)) { + final ClientFuture request = new ClientFuture(); + session.getScheduler().execute(new Runnable() { + + @Override + public void run() { + checkClosed(); + close(request); + session.pumpToProtonTransport(request); + } + }); + + request.sync(); + } + } + + /** + * @return this session's parent AmqpSession. + */ + public AmqpSession getSession() { + return session; + } + + /** + * @return an unmodifiable view of the underlying Sender instance. + */ + public Sender getSender() { + return new UnmodifiableSender(getEndpoint()); + } + + /** + * @return the assigned address of this sender. + */ + public String getAddress() { + return address; + } + + //----- Sender configuration ---------------------------------------------// + + /** + * @return will messages be settle on send. + */ + public boolean isPresettle() { + return presettle; + } + + /** + * Configure is sent messages are marked as settled on send, defaults to false. + * + * @param presettle configure if this sender will presettle all sent messages. + */ + public void setPresettle(boolean presettle) { + this.presettle = presettle; + } + + /** + * @return the currently configured send timeout. + */ + public long getSendTimeout() { + return sendTimeout; + } + + /** + * Sets the amount of time the sender will block on a send before failing. + * + * @param sendTimeout time in milliseconds to wait. + */ + public void setSendTimeout(long sendTimeout) { + this.sendTimeout = sendTimeout; + } + + //----- Private Sender implementation ------------------------------------// + + private void checkClosed() { + if (isClosed()) { + throw new IllegalStateException("Sender is already closed"); + } + } + + @Override + protected void doOpen() { + + Symbol[] outcomes = new Symbol[]{Accepted.DESCRIPTOR_SYMBOL, Rejected.DESCRIPTOR_SYMBOL}; + Source source = new Source(); + source.setAddress(senderId); + source.setOutcomes(outcomes); + + Target target = userSpecifiedTarget; + if (target == null) { + target = new Target(); + target.setAddress(address); + } + + String senderName = senderId + ":" + address; + + Sender sender = session.getEndpoint().sender(senderName); + sender.setSource(source); + sender.setTarget(target); + if (presettle) { + sender.setSenderSettleMode(SenderSettleMode.SETTLED); + } + else { + sender.setSenderSettleMode(SenderSettleMode.UNSETTLED); + } + sender.setReceiverSettleMode(ReceiverSettleMode.FIRST); + + setEndpoint(sender); + + super.doOpen(); + } + + @Override + protected void doOpenCompletion() { + // Verify the attach response contained a non-null target + org.apache.qpid.proton.amqp.transport.Target t = getEndpoint().getRemoteTarget(); + if (t != null) { + super.doOpenCompletion(); + } + else { + // No link terminus was created, the peer will now detach/close us. + } + } + + @Override + protected void doOpenInspection() { + try { + getStateInspector().inspectOpenedResource(getSender()); + } + catch (Throwable error) { + getStateInspector().markAsInvalid(error.getMessage()); + } + } + + @Override + protected void doClosedInspection() { + try { + getStateInspector().inspectClosedResource(getSender()); + } + catch (Throwable error) { + getStateInspector().markAsInvalid(error.getMessage()); + } + } + + @Override + protected void doDetachedInspection() { + try { + getStateInspector().inspectDetachedResource(getSender()); + } + catch (Throwable error) { + getStateInspector().markAsInvalid(error.getMessage()); + } + } + + @Override + protected Exception getOpenAbortException() { + // Verify the attach response contained a non-null target + org.apache.qpid.proton.amqp.transport.Target t = getEndpoint().getRemoteTarget(); + if (t != null) { + return super.getOpenAbortException(); + } + else { + // No link terminus was created, the peer has detach/closed us, create IDE. + return new InvalidDestinationException("Link creation was refused"); + } + } + + private void doSend(AmqpMessage message, AsyncResult request) throws Exception { + LOG.trace("Producer sending message: {}", message); + + Delivery delivery = null; + if (presettle) { + delivery = getEndpoint().delivery(EMPTY_BYTE_ARRAY, 0, 0); + } + else { + byte[] tag = tagGenerator.getNextTag(); + delivery = getEndpoint().delivery(tag, 0, tag.length); + } + + delivery.setContext(request); + + if (session.isInTransaction()) { + Binary amqpTxId = session.getTransactionId().getRemoteTxId(); + TransactionalState state = new TransactionalState(); + state.setTxnId(amqpTxId); + delivery.disposition(state); + } + + encodeAndSend(message.getWrappedMessage(), delivery); + + if (presettle) { + delivery.settle(); + request.onSuccess(); + } + else { + pending.add(delivery); + getEndpoint().advance(); + } + } + + private void encodeAndSend(Message message, Delivery delivery) throws IOException { + + int encodedSize; + while (true) { + try { + encodedSize = message.encode(encodeBuffer, 0, encodeBuffer.length); + break; + } + catch (java.nio.BufferOverflowException e) { + encodeBuffer = new byte[encodeBuffer.length * 2]; + } + } + + int sentSoFar = 0; + + while (true) { + int sent = getEndpoint().send(encodeBuffer, sentSoFar, encodedSize - sentSoFar); + if (sent > 0) { + sentSoFar += sent; + if ((encodedSize - sentSoFar) == 0) { + break; + } + } + else { + LOG.warn("{} failed to send any data from current Message.", this); + } + } + } + + @Override + public void processDeliveryUpdates(AmqpConnection connection) throws IOException { + List toRemove = new ArrayList<>(); + + for (Delivery delivery : pending) { + DeliveryState state = delivery.getRemoteState(); + if (state == null) { + continue; + } + + Outcome outcome = null; + if (state instanceof TransactionalState) { + LOG.trace("State of delivery is Transactional, retrieving outcome: {}", state); + outcome = ((TransactionalState) state).getOutcome(); + } + else if (state instanceof Outcome) { + outcome = (Outcome) state; + } + else { + LOG.warn("Message send updated with unsupported state: {}", state); + outcome = null; + } + + AsyncResult request = (AsyncResult) delivery.getContext(); + Exception deliveryError = null; + + if (outcome instanceof Accepted) { + LOG.trace("Outcome of delivery was accepted: {}", delivery); + if (request != null && !request.isComplete()) { + request.onSuccess(); + } + } + else if (outcome instanceof Rejected) { + LOG.trace("Outcome of delivery was rejected: {}", delivery); + ErrorCondition remoteError = ((Rejected) outcome).getError(); + if (remoteError == null) { + remoteError = getEndpoint().getRemoteCondition(); + } + + deliveryError = AmqpSupport.convertToException(remoteError); + } + else if (outcome instanceof Released) { + LOG.trace("Outcome of delivery was released: {}", delivery); + deliveryError = new IOException("Delivery failed: released by receiver"); + } + else if (outcome instanceof Modified) { + LOG.trace("Outcome of delivery was modified: {}", delivery); + deliveryError = new IOException("Delivery failed: failure at remote"); + } + + if (deliveryError != null) { + if (request != null && !request.isComplete()) { + request.onFailure(deliveryError); + } + else { + connection.fireClientException(deliveryError); + } + } + + tagGenerator.returnTag(delivery.getTag()); + delivery.settle(); + toRemove.add(delivery); + } + + pending.removeAll(toRemove); + } + + @Override + public String toString() { + return getClass().getSimpleName() + "{ address = " + address + "}"; + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpSession.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpSession.java new file mode 100644 index 0000000000..28e38f2748 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpSession.java @@ -0,0 +1,454 @@ +/** + * 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 org.apache.activemq.transport.amqp.client; + +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.atomic.AtomicLong; + +import org.apache.activemq.transport.amqp.client.util.AsyncResult; +import org.apache.activemq.transport.amqp.client.util.ClientFuture; +import org.apache.activemq.transport.amqp.client.util.UnmodifiableSession; +import org.apache.qpid.proton.amqp.messaging.Source; +import org.apache.qpid.proton.amqp.messaging.Target; +import org.apache.qpid.proton.engine.Connection; +import org.apache.qpid.proton.engine.Session; + +/** + * Session class that manages a Proton session endpoint. + */ +public class AmqpSession extends AmqpAbstractResource { + + private final AtomicLong receiverIdGenerator = new AtomicLong(); + private final AtomicLong senderIdGenerator = new AtomicLong(); + + private final AmqpConnection connection; + private final String sessionId; + private final AmqpTransactionContext txContext; + + /** + * Create a new session instance. + * + * @param connection The parent connection that created the session. + * @param sessionId The unique ID value assigned to this session. + */ + public AmqpSession(AmqpConnection connection, String sessionId) { + this.connection = connection; + this.sessionId = sessionId; + this.txContext = new AmqpTransactionContext(this); + } + + /** + * Create a sender instance using the given address + * + * @param address the address to which the sender will produce its messages. + * @return a newly created sender that is ready for use. + * @throws Exception if an error occurs while creating the sender. + */ + public AmqpSender createSender(final String address) throws Exception { + return createSender(address, false); + } + + /** + * Create a sender instance using the given address + * + * @param address the address to which the sender will produce its messages. + * @param presettle controls if the created sender produces message that have already been marked settled. + * @return a newly created sender that is ready for use. + * @throws Exception if an error occurs while creating the sender. + */ + public AmqpSender createSender(final String address, boolean presettle) throws Exception { + checkClosed(); + + final AmqpSender sender = new AmqpSender(AmqpSession.this, address, getNextSenderId()); + sender.setPresettle(presettle); + final ClientFuture request = new ClientFuture(); + + connection.getScheduler().execute(new Runnable() { + + @Override + public void run() { + checkClosed(); + sender.setStateInspector(getStateInspector()); + sender.open(request); + pumpToProtonTransport(request); + } + }); + + request.sync(); + + return sender; + } + + /** + * Create a sender instance using the given Target + * + * @param target the caller created and configured Traget used to create the sender link. + * @return a newly created sender that is ready for use. + * @throws Exception if an error occurs while creating the receiver. + */ + public AmqpSender createSender(Target target) throws Exception { + checkClosed(); + + final AmqpSender sender = new AmqpSender(AmqpSession.this, target, getNextSenderId()); + final ClientFuture request = new ClientFuture(); + + connection.getScheduler().execute(new Runnable() { + + @Override + public void run() { + checkClosed(); + sender.setStateInspector(getStateInspector()); + sender.open(request); + pumpToProtonTransport(request); + } + }); + + request.sync(); + + return sender; + } + + /** + * Create a receiver instance using the given address + * + * @param address the address to which the receiver will subscribe for its messages. + * @return a newly created receiver that is ready for use. + * @throws Exception if an error occurs while creating the receiver. + */ + public AmqpReceiver createReceiver(String address) throws Exception { + return createReceiver(address, null, false); + } + + /** + * Create a receiver instance using the given address + * + * @param address the address to which the receiver will subscribe for its messages. + * @param selector the JMS selector to use for the subscription + * @return a newly created receiver that is ready for use. + * @throws Exception if an error occurs while creating the receiver. + */ + public AmqpReceiver createReceiver(String address, String selector) throws Exception { + return createReceiver(address, selector, false); + } + + /** + * Create a receiver instance using the given address + * + * @param address the address to which the receiver will subscribe for its messages. + * @param selector the JMS selector to use for the subscription + * @param noLocal should the subscription have messages from its connection filtered. + * @return a newly created receiver that is ready for use. + * @throws Exception if an error occurs while creating the receiver. + */ + public AmqpReceiver createReceiver(String address, String selector, boolean noLocal) throws Exception { + return createReceiver(address, selector, noLocal, false); + } + + /** + * Create a receiver instance using the given address + * + * @param address the address to which the receiver will subscribe for its messages. + * @param selector the JMS selector to use for the subscription + * @param noLocal should the subscription have messages from its connection filtered. + * @param presettle should the receiver be created with a settled sender mode. + * @return a newly created receiver that is ready for use. + * @throws Exception if an error occurs while creating the receiver. + */ + public AmqpReceiver createReceiver(String address, + String selector, + boolean noLocal, + boolean presettle) throws Exception { + checkClosed(); + + final ClientFuture request = new ClientFuture(); + final AmqpReceiver receiver = new AmqpReceiver(AmqpSession.this, address, getNextReceiverId()); + + receiver.setNoLocal(noLocal); + receiver.setPresettle(presettle); + if (selector != null && !selector.isEmpty()) { + receiver.setSelector(selector); + } + + connection.getScheduler().execute(new Runnable() { + + @Override + public void run() { + checkClosed(); + receiver.setStateInspector(getStateInspector()); + receiver.open(request); + pumpToProtonTransport(request); + } + }); + + request.sync(); + + return receiver; + } + + /** + * Create a receiver instance using the given Source + * + * @param source the caller created and configured Source used to create the receiver link. + * @return a newly created receiver that is ready for use. + * @throws Exception if an error occurs while creating the receiver. + */ + public AmqpReceiver createReceiver(Source source) throws Exception { + checkClosed(); + + final ClientFuture request = new ClientFuture(); + final AmqpReceiver receiver = new AmqpReceiver(AmqpSession.this, source, getNextReceiverId()); + + connection.getScheduler().execute(new Runnable() { + + @Override + public void run() { + checkClosed(); + receiver.setStateInspector(getStateInspector()); + receiver.open(request); + pumpToProtonTransport(request); + } + }); + + request.sync(); + + return receiver; + } + + /** + * Create a receiver instance using the given address that creates a durable subscription. + * + * @param address the address to which the receiver will subscribe for its messages. + * @param subscriptionName the name of the subscription that is being created. + * @return a newly created receiver that is ready for use. + * @throws Exception if an error occurs while creating the receiver. + */ + public AmqpReceiver createDurableReceiver(String address, String subscriptionName) throws Exception { + return createDurableReceiver(address, subscriptionName, null, false); + } + + /** + * Create a receiver instance using the given address that creates a durable subscription. + * + * @param address the address to which the receiver will subscribe for its messages. + * @param subscriptionName the name of the subscription that is being created. + * @param selector the JMS selector to use for the subscription + * @return a newly created receiver that is ready for use. + * @throws Exception if an error occurs while creating the receiver. + */ + public AmqpReceiver createDurableReceiver(String address, + String subscriptionName, + String selector) throws Exception { + return createDurableReceiver(address, subscriptionName, selector, false); + } + + /** + * Create a receiver instance using the given address that creates a durable subscription. + * + * @param address the address to which the receiver will subscribe for its messages. + * @param subscriptionName the name of the subscription that is being created. + * @param selector the JMS selector to use for the subscription + * @param noLocal should the subscription have messages from its connection filtered. + * @return a newly created receiver that is ready for use. + * @throws Exception if an error occurs while creating the receiver. + */ + public AmqpReceiver createDurableReceiver(String address, + String subscriptionName, + String selector, + boolean noLocal) throws Exception { + checkClosed(); + + if (subscriptionName == null || subscriptionName.isEmpty()) { + throw new IllegalArgumentException("subscription name must not be null or empty."); + } + + final ClientFuture request = new ClientFuture(); + final AmqpReceiver receiver = new AmqpReceiver(AmqpSession.this, address, getNextReceiverId()); + receiver.setSubscriptionName(subscriptionName); + receiver.setNoLocal(noLocal); + if (selector != null && !selector.isEmpty()) { + receiver.setSelector(selector); + } + + connection.getScheduler().execute(new Runnable() { + + @Override + public void run() { + checkClosed(); + receiver.setStateInspector(getStateInspector()); + receiver.open(request); + pumpToProtonTransport(request); + } + }); + + request.sync(); + + return receiver; + } + + /** + * Create a receiver instance using the given address that creates a durable subscription. + * + * @param subscriptionName the name of the subscription that should be queried for on the remote.. + * @return a newly created receiver that is ready for use if the subscription exists. + * @throws Exception if an error occurs while creating the receiver. + */ + public AmqpReceiver lookupSubscription(String subscriptionName) throws Exception { + checkClosed(); + + if (subscriptionName == null || subscriptionName.isEmpty()) { + throw new IllegalArgumentException("subscription name must not be null or empty."); + } + + final ClientFuture request = new ClientFuture(); + final AmqpReceiver receiver = new AmqpReceiver(AmqpSession.this, (String) null, getNextReceiverId()); + receiver.setSubscriptionName(subscriptionName); + + connection.getScheduler().execute(new Runnable() { + + @Override + public void run() { + checkClosed(); + receiver.setStateInspector(getStateInspector()); + receiver.open(request); + pumpToProtonTransport(request); + } + }); + + request.sync(); + + return receiver; + } + + /** + * @return this session's parent AmqpConnection. + */ + public AmqpConnection getConnection() { + return connection; + } + + public Session getSession() { + return new UnmodifiableSession(getEndpoint()); + } + + public boolean isInTransaction() { + return txContext.isInTransaction(); + } + + @Override + public String toString() { + return "AmqpSession { " + sessionId + " }"; + } + + //----- Session Transaction Methods --------------------------------------// + + /** + * Starts a new transaction associated with this session. + * + * @throws Exception if an error occurs starting a new Transaction. + */ + public void begin() throws Exception { + if (txContext.isInTransaction()) { + throw new javax.jms.IllegalStateException("Session already has an active transaction"); + } + + txContext.begin(); + } + + /** + * Commit the current transaction associated with this session. + * + * @throws Exception if an error occurs committing the Transaction. + */ + public void commit() throws Exception { + if (!txContext.isInTransaction()) { + throw new javax.jms.IllegalStateException("Commit called on Session that does not have an active transaction"); + } + + txContext.commit(); + } + + /** + * Roll back the current transaction associated with this session. + * + * @throws Exception if an error occurs rolling back the Transaction. + */ + public void rollback() throws Exception { + if (!txContext.isInTransaction()) { + throw new javax.jms.IllegalStateException("Rollback called on Session that does not have an active transaction"); + } + + txContext.rollback(); + } + + //----- Internal access used to manage resources -------------------------// + + ScheduledExecutorService getScheduler() { + return connection.getScheduler(); + } + + Connection getProtonConnection() { + return connection.getProtonConnection(); + } + + void pumpToProtonTransport(AsyncResult request) { + connection.pumpToProtonTransport(request); + } + + AmqpTransactionId getTransactionId() { + return txContext.getTransactionId(); + } + + AmqpTransactionContext getTransactionContext() { + return txContext; + } + + //----- Private implementation details -----------------------------------// + + @Override + protected void doOpenInspection() { + try { + getStateInspector().inspectOpenedResource(getSession()); + } + catch (Throwable error) { + getStateInspector().markAsInvalid(error.getMessage()); + } + } + + @Override + protected void doClosedInspection() { + try { + getStateInspector().inspectClosedResource(getSession()); + } + catch (Throwable error) { + getStateInspector().markAsInvalid(error.getMessage()); + } + } + + private String getNextSenderId() { + return sessionId + ":" + senderIdGenerator.incrementAndGet(); + } + + private String getNextReceiverId() { + return sessionId + ":" + receiverIdGenerator.incrementAndGet(); + } + + private void checkClosed() { + if (isClosed() || connection.isClosed()) { + throw new IllegalStateException("Session is already closed"); + } + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpSupport.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpSupport.java new file mode 100644 index 0000000000..c9ee57b8cc --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpSupport.java @@ -0,0 +1,195 @@ +/* + * 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 org.apache.activemq.transport.amqp.client; + +import javax.jms.InvalidClientIDException; +import javax.jms.InvalidDestinationException; +import javax.jms.JMSException; +import javax.jms.JMSSecurityException; +import javax.jms.ResourceAllocationException; +import javax.jms.TransactionRolledBackException; +import java.io.IOException; +import java.util.Map; + +import org.apache.qpid.proton.amqp.Symbol; +import org.apache.qpid.proton.amqp.messaging.Modified; +import org.apache.qpid.proton.amqp.messaging.Rejected; +import org.apache.qpid.proton.amqp.transaction.TransactionErrors; +import org.apache.qpid.proton.amqp.transport.AmqpError; +import org.apache.qpid.proton.amqp.transport.ConnectionError; +import org.apache.qpid.proton.amqp.transport.ErrorCondition; + +public class AmqpSupport { + + // Symbols used for connection capabilities + public static final Symbol SOLE_CONNECTION_CAPABILITY = Symbol.valueOf("sole-connection-for-container"); + public static final Symbol ANONYMOUS_RELAY = Symbol.valueOf("ANONYMOUS-RELAY"); + + // Symbols used to announce connection error information + public static final Symbol CONNECTION_OPEN_FAILED = Symbol.valueOf("amqp:connection-establishment-failed"); + public static final Symbol INVALID_FIELD = Symbol.valueOf("invalid-field"); + public static final Symbol CONTAINER_ID = Symbol.valueOf("container-id"); + + // Symbols used to announce connection redirect ErrorCondition 'info' + public static final Symbol PORT = Symbol.valueOf("port"); + public static final Symbol NETWORK_HOST = Symbol.valueOf("network-host"); + public static final Symbol OPEN_HOSTNAME = Symbol.valueOf("hostname"); + + // Symbols used for connection properties + public static final Symbol QUEUE_PREFIX = Symbol.valueOf("queue-prefix"); + public static final Symbol TOPIC_PREFIX = Symbol.valueOf("topic-prefix"); + + public static final Symbol PRODUCT = Symbol.valueOf("product"); + public static final Symbol VERSION = Symbol.valueOf("version"); + public static final Symbol PLATFORM = Symbol.valueOf("platform"); + + // Symbols used for receivers. + public static final Symbol COPY = Symbol.getSymbol("copy"); + public static final Symbol NO_LOCAL_SYMBOL = Symbol.valueOf("no-local"); + public static final Symbol SELECTOR_SYMBOL = Symbol.valueOf("jms-selector"); + + // Delivery states + public static final Rejected REJECTED = new Rejected(); + public static final Modified MODIFIED_FAILED = new Modified(); + public static final Modified MODIFIED_FAILED_UNDELIVERABLE = new Modified(); + + // Temporary Destination constants + public static final Symbol DYNAMIC_NODE_LIFETIME_POLICY = Symbol.valueOf("lifetime-policy"); + public static final String TEMP_QUEUE_CREATOR = "temp-queue-creator:"; + public static final String TEMP_TOPIC_CREATOR = "temp-topic-creator:"; + + //----- Static initializer -----------------------------------------------// + + static { + MODIFIED_FAILED.setDeliveryFailed(true); + + MODIFIED_FAILED_UNDELIVERABLE.setDeliveryFailed(true); + MODIFIED_FAILED_UNDELIVERABLE.setUndeliverableHere(true); + } + + //----- Utility Methods --------------------------------------------------// + + /** + * Given an ErrorCondition instance create a new Exception that best matches + * the error type. + * + * @param errorCondition The ErrorCondition returned from the remote peer. + * @return a new Exception instance that best matches the ErrorCondition value. + */ + public static Exception convertToException(ErrorCondition errorCondition) { + Exception remoteError = null; + + if (errorCondition != null && errorCondition.getCondition() != null) { + Symbol error = errorCondition.getCondition(); + String message = extractErrorMessage(errorCondition); + + if (error.equals(AmqpError.UNAUTHORIZED_ACCESS)) { + remoteError = new JMSSecurityException(message); + } + else if (error.equals(AmqpError.RESOURCE_LIMIT_EXCEEDED)) { + remoteError = new ResourceAllocationException(message); + } + else if (error.equals(AmqpError.NOT_FOUND)) { + remoteError = new InvalidDestinationException(message); + } + else if (error.equals(TransactionErrors.TRANSACTION_ROLLBACK)) { + remoteError = new TransactionRolledBackException(message); + } + else if (error.equals(ConnectionError.REDIRECT)) { + remoteError = createRedirectException(error, message, errorCondition); + } + else if (error.equals(AmqpError.INVALID_FIELD)) { + Map info = errorCondition.getInfo(); + if (info != null && CONTAINER_ID.equals(info.get(INVALID_FIELD))) { + remoteError = new InvalidClientIDException(message); + } + else { + remoteError = new JMSException(message); + } + } + else { + remoteError = new JMSException(message); + } + } + else { + remoteError = new JMSException("Unknown error from remote peer"); + } + + return remoteError; + } + + /** + * Attempt to read and return the embedded error message in the given ErrorCondition + * object. If no message can be extracted a generic message is returned. + * + * @param errorCondition The ErrorCondition to extract the error message from. + * @return an error message extracted from the given ErrorCondition. + */ + public static String extractErrorMessage(ErrorCondition errorCondition) { + String message = "Received error from remote peer without description"; + if (errorCondition != null) { + if (errorCondition.getDescription() != null && !errorCondition.getDescription().isEmpty()) { + message = errorCondition.getDescription(); + } + + Symbol condition = errorCondition.getCondition(); + if (condition != null) { + message = message + " [condition = " + condition + "]"; + } + } + + return message; + } + + /** + * When a redirect type exception is received this method is called to create the + * appropriate redirect exception type containing the error details needed. + * + * @param error the Symbol that defines the redirection error type. + * @param message the basic error message that should used or amended for the returned exception. + * @param condition the ErrorCondition that describes the redirection. + * @return an Exception that captures the details of the redirection error. + */ + public static Exception createRedirectException(Symbol error, String message, ErrorCondition condition) { + Exception result = null; + Map info = condition.getInfo(); + + if (info == null) { + result = new IOException(message + " : Redirection information not set."); + } + else { + String hostname = (String) info.get(OPEN_HOSTNAME); + + String networkHost = (String) info.get(NETWORK_HOST); + if (networkHost == null || networkHost.isEmpty()) { + result = new IOException(message + " : Redirection information not set."); + } + + int port = 0; + try { + port = Integer.valueOf(info.get(PORT).toString()); + } + catch (Exception ex) { + result = new IOException(message + " : Redirection information not set."); + } + + result = new AmqpRedirectedException(message, hostname, networkHost, port); + } + + return result; + } +} \ No newline at end of file diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpTransactionContext.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpTransactionContext.java new file mode 100644 index 0000000000..dcf23d2dcf --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpTransactionContext.java @@ -0,0 +1,261 @@ +/** + * 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 org.apache.activemq.transport.amqp.client; + +import java.io.IOException; +import java.util.LinkedHashSet; +import java.util.Set; + +import org.apache.activemq.transport.amqp.client.util.AsyncResult; +import org.apache.activemq.transport.amqp.client.util.ClientFuture; +import org.apache.activemq.transport.amqp.client.util.ClientFutureSynchronization; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Defines a context under which resources in a given session + * will operate inside transaction scoped boundaries. + */ +public class AmqpTransactionContext { + + private static final Logger LOG = LoggerFactory.getLogger(AmqpTransactionContext.class); + + private final AmqpSession session; + private final Set txReceivers = new LinkedHashSet<>(); + + private AmqpTransactionCoordinator coordinator; + private AmqpTransactionId transactionId; + + public AmqpTransactionContext(AmqpSession session) { + this.session = session; + } + + /** + * Begins a new transaction scoped to the target session. + * + * @param txId The transaction Id to use for this new transaction. + * @throws Exception if an error occurs while starting the transaction. + */ + public void begin() throws Exception { + if (transactionId != null) { + throw new IOException("Begin called while a TX is still Active."); + } + + final AmqpTransactionId txId = session.getConnection().getNextTransactionId(); + final ClientFuture request = new ClientFuture(new ClientFutureSynchronization() { + + @Override + public void onPendingSuccess() { + transactionId = txId; + } + + @Override + public void onPendingFailure(Throwable cause) { + transactionId = null; + } + }); + + LOG.info("Attempting to Begin TX:[{}]", txId); + + session.getScheduler().execute(new Runnable() { + + @Override + public void run() { + if (coordinator == null || coordinator.isClosed()) { + LOG.info("Creating new Coordinator for TX:[{}]", txId); + coordinator = new AmqpTransactionCoordinator(session); + coordinator.open(new AsyncResult() { + + @Override + public void onSuccess() { + try { + LOG.info("Attempting to declare TX:[{}]", txId); + coordinator.declare(txId, request); + } + catch (Exception e) { + request.onFailure(e); + } + } + + @Override + public void onFailure(Throwable result) { + request.onFailure(result); + } + + @Override + public boolean isComplete() { + return request.isComplete(); + } + }); + } + else { + try { + LOG.info("Attempting to declare TX:[{}]", txId); + coordinator.declare(txId, request); + } + catch (Exception e) { + request.onFailure(e); + } + } + + session.pumpToProtonTransport(request); + } + }); + + request.sync(); + } + + /** + * Commit this transaction which then ends the lifetime of the transacted operation. + * + * @throws Exception if an error occurs while performing the commit + */ + public void commit() throws Exception { + if (transactionId == null) { + throw new IllegalStateException("Commit called with no active Transaction."); + } + + preCommit(); + + final ClientFuture request = new ClientFuture(new ClientFutureSynchronization() { + + @Override + public void onPendingSuccess() { + transactionId = null; + postCommit(); + } + + @Override + public void onPendingFailure(Throwable cause) { + transactionId = null; + postCommit(); + } + }); + + LOG.debug("Commit on TX[{}] initiated", transactionId); + session.getScheduler().execute(new Runnable() { + + @Override + public void run() { + try { + LOG.info("Attempting to commit TX:[{}]", transactionId); + coordinator.discharge(transactionId, request, true); + session.pumpToProtonTransport(request); + } + catch (Exception e) { + request.onFailure(e); + } + } + }); + + request.sync(); + } + + /** + * Rollback any transacted work performed under the current transaction. + * + * @throws Exception if an error occurs during the rollback operation. + */ + public void rollback() throws Exception { + if (transactionId == null) { + throw new IllegalStateException("Rollback called with no active Transaction."); + } + + preRollback(); + + final ClientFuture request = new ClientFuture(new ClientFutureSynchronization() { + + @Override + public void onPendingSuccess() { + transactionId = null; + postRollback(); + } + + @Override + public void onPendingFailure(Throwable cause) { + transactionId = null; + postRollback(); + } + }); + + LOG.debug("Rollback on TX[{}] initiated", transactionId); + session.getScheduler().execute(new Runnable() { + + @Override + public void run() { + try { + LOG.info("Attempting to roll back TX:[{}]", transactionId); + coordinator.discharge(transactionId, request, false); + session.pumpToProtonTransport(request); + } + catch (Exception e) { + request.onFailure(e); + } + } + }); + + request.sync(); + } + + //----- Internal access to context properties ----------------------------// + + AmqpTransactionCoordinator getCoordinator() { + return coordinator; + } + + AmqpTransactionId getTransactionId() { + return transactionId; + } + + boolean isInTransaction() { + return transactionId != null; + } + + void registerTxConsumer(AmqpReceiver consumer) { + txReceivers.add(consumer); + } + + //----- Transaction pre / post completion --------------------------------// + + private void preCommit() { + for (AmqpReceiver receiver : txReceivers) { + receiver.preCommit(); + } + } + + private void preRollback() { + for (AmqpReceiver receiver : txReceivers) { + receiver.preRollback(); + } + } + + private void postCommit() { + for (AmqpReceiver receiver : txReceivers) { + receiver.postCommit(); + } + + txReceivers.clear(); + } + + private void postRollback() { + for (AmqpReceiver receiver : txReceivers) { + receiver.postRollback(); + } + + txReceivers.clear(); + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpTransactionCoordinator.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpTransactionCoordinator.java new file mode 100644 index 0000000000..aded162a8f --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpTransactionCoordinator.java @@ -0,0 +1,262 @@ +/* + * 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 org.apache.activemq.transport.amqp.client; + +import javax.jms.IllegalStateException; +import javax.jms.JMSException; +import javax.jms.TransactionRolledBackException; +import java.io.IOException; +import java.nio.BufferOverflowException; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +import org.apache.activemq.transport.amqp.client.util.AsyncResult; +import org.apache.activemq.transport.amqp.client.util.IOExceptionSupport; +import org.apache.qpid.proton.amqp.messaging.AmqpValue; +import org.apache.qpid.proton.amqp.messaging.Rejected; +import org.apache.qpid.proton.amqp.messaging.Source; +import org.apache.qpid.proton.amqp.transaction.Coordinator; +import org.apache.qpid.proton.amqp.transaction.Declare; +import org.apache.qpid.proton.amqp.transaction.Declared; +import org.apache.qpid.proton.amqp.transaction.Discharge; +import org.apache.qpid.proton.amqp.transaction.TxnCapability; +import org.apache.qpid.proton.amqp.transport.DeliveryState; +import org.apache.qpid.proton.amqp.transport.ReceiverSettleMode; +import org.apache.qpid.proton.amqp.transport.SenderSettleMode; +import org.apache.qpid.proton.engine.Delivery; +import org.apache.qpid.proton.engine.Sender; +import org.apache.qpid.proton.message.Message; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Represents the AMQP Transaction coordinator link used by the transaction context + * of a session to control the lifetime of a given transaction. + */ +public class AmqpTransactionCoordinator extends AmqpAbstractResource { + + private static final Logger LOG = LoggerFactory.getLogger(AmqpTransactionCoordinator.class); + + private final byte[] OUTBOUND_BUFFER = new byte[64]; + + private final AmqpSession session; + private final AmqpTransferTagGenerator tagGenerator = new AmqpTransferTagGenerator(); + + private List pendingDeliveries = new LinkedList<>(); + private Map pendingRequests = new HashMap<>(); + + public AmqpTransactionCoordinator(AmqpSession session) { + this.session = session; + } + + @Override + public void processDeliveryUpdates(AmqpConnection connection) throws IOException { + try { + Iterator deliveries = pendingDeliveries.iterator(); + while (deliveries.hasNext()) { + Delivery pendingDelivery = deliveries.next(); + if (!pendingDelivery.remotelySettled()) { + continue; + } + + DeliveryState state = pendingDelivery.getRemoteState(); + AmqpTransactionId txId = (AmqpTransactionId) pendingDelivery.getContext(); + AsyncResult pendingRequest = pendingRequests.get(txId); + + if (pendingRequest == null) { + throw new IllegalStateException("Pending tx operation with no pending request"); + } + + if (state instanceof Declared) { + LOG.debug("New TX started: {}", txId.getTxId()); + Declared declared = (Declared) state; + txId.setRemoteTxId(declared.getTxnId()); + pendingRequest.onSuccess(); + } + else if (state instanceof Rejected) { + LOG.debug("Last TX request failed: {}", txId.getTxId()); + Rejected rejected = (Rejected) state; + Exception cause = AmqpSupport.convertToException(rejected.getError()); + JMSException failureCause = null; + if (txId.isCommit()) { + failureCause = new TransactionRolledBackException(cause.getMessage()); + } + else { + failureCause = new JMSException(cause.getMessage()); + } + + pendingRequest.onFailure(failureCause); + } + else { + LOG.debug("Last TX request succeeded: {}", txId.getTxId()); + pendingRequest.onSuccess(); + } + + // Clear state data + pendingDelivery.settle(); + pendingRequests.remove(txId); + deliveries.remove(); + } + + super.processDeliveryUpdates(connection); + } + catch (Exception e) { + throw IOExceptionSupport.create(e); + } + } + + public void declare(AmqpTransactionId txId, AsyncResult request) throws Exception { + if (txId.getRemoteTxId() != null) { + throw new IllegalStateException("Declar called while a TX is still Active."); + } + + if (isClosed()) { + request.onFailure(new JMSException("Cannot start new transaction: Coordinator remotely closed")); + return; + } + + Message message = Message.Factory.create(); + Declare declare = new Declare(); + message.setBody(new AmqpValue(declare)); + + Delivery pendingDelivery = getEndpoint().delivery(tagGenerator.getNextTag()); + pendingDelivery.setContext(txId); + + // Store away for completion + pendingDeliveries.add(pendingDelivery); + pendingRequests.put(txId, request); + + sendTxCommand(message); + } + + public void discharge(AmqpTransactionId txId, AsyncResult request, boolean commit) throws Exception { + + if (isClosed()) { + Exception failureCause = null; + + if (commit) { + failureCause = new TransactionRolledBackException("Transaction inbout: Coordinator remotely closed"); + } + else { + failureCause = new JMSException("Rollback cannot complete: Coordinator remotely closed"); + } + + request.onFailure(failureCause); + return; + } + + // Store the context of this action in the transaction ID for later completion. + txId.setState(commit ? AmqpTransactionId.COMMIT_MARKER : AmqpTransactionId.ROLLBACK_MARKER); + + Message message = Message.Factory.create(); + Discharge discharge = new Discharge(); + discharge.setFail(!commit); + discharge.setTxnId(txId.getRemoteTxId()); + message.setBody(new AmqpValue(discharge)); + + Delivery pendingDelivery = getEndpoint().delivery(tagGenerator.getNextTag()); + pendingDelivery.setContext(txId); + + // Store away for completion + pendingDeliveries.add(pendingDelivery); + pendingRequests.put(txId, request); + + sendTxCommand(message); + } + + //----- Base class overrides ---------------------------------------------// + + @Override + public void remotelyClosed(AmqpConnection connection) { + + Exception txnError = AmqpSupport.convertToException(getEndpoint().getRemoteCondition()); + + // Alert any pending operation that the link failed to complete the pending + // begin / commit / rollback operation. + for (AsyncResult pendingRequest : pendingRequests.values()) { + pendingRequest.onFailure(txnError); + } + + // Purge linkages to pending operations. + pendingDeliveries.clear(); + pendingRequests.clear(); + + // Override the base class version because we do not want to propagate + // an error up to the client if remote close happens as that is an + // acceptable way for the remote to indicate the discharge could not + // be applied. + + if (getEndpoint() != null) { + getEndpoint().close(); + getEndpoint().free(); + } + + LOG.debug("Transaction Coordinator link {} was remotely closed", getEndpoint()); + } + + //----- Internal implementation ------------------------------------------// + + private void sendTxCommand(Message message) throws IOException { + int encodedSize = 0; + byte[] buffer = OUTBOUND_BUFFER; + while (true) { + try { + encodedSize = message.encode(buffer, 0, buffer.length); + break; + } + catch (BufferOverflowException e) { + buffer = new byte[buffer.length * 2]; + } + } + + Sender sender = getEndpoint(); + sender.send(buffer, 0, encodedSize); + sender.advance(); + } + + @Override + protected void doOpen() { + Coordinator coordinator = new Coordinator(); + coordinator.setCapabilities(TxnCapability.LOCAL_TXN); + Source source = new Source(); + + String coordinatorName = "qpid-jms:coordinator:" + session.getConnection().getConnectionId(); + + Sender sender = session.getEndpoint().sender(coordinatorName); + sender.setSource(source); + sender.setTarget(coordinator); + sender.setSenderSettleMode(SenderSettleMode.UNSETTLED); + sender.setReceiverSettleMode(ReceiverSettleMode.FIRST); + + setEndpoint(sender); + + super.doOpen(); + } + + @Override + protected void doOpenInspection() { + // TODO + } + + @Override + protected void doClosedInspection() { + // TODO + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpTransactionId.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpTransactionId.java new file mode 100644 index 0000000000..5dcdfe1c1e --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpTransactionId.java @@ -0,0 +1,98 @@ +/** + * 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 org.apache.activemq.transport.amqp.client; + +import org.apache.qpid.proton.amqp.Binary; + +/** + * Wrapper For Transaction state in identification + */ +public class AmqpTransactionId { + + public static final int DECLARE_MARKER = 1; + public static final int ROLLBACK_MARKER = 2; + public static final int COMMIT_MARKER = 3; + + private final String txId; + private Binary remoteTxId; + private int state = DECLARE_MARKER; + + public AmqpTransactionId(String txId) { + this.txId = txId; + } + + public boolean isDeclare() { + return state == DECLARE_MARKER; + } + + public boolean isCommit() { + return state == COMMIT_MARKER; + } + + public boolean isRollback() { + return state == ROLLBACK_MARKER; + } + + public void setState(int state) { + this.state = state; + } + + public String getTxId() { + return txId; + } + + public Binary getRemoteTxId() { + return remoteTxId; + } + + public void setRemoteTxId(Binary remoteTxId) { + this.remoteTxId = remoteTxId; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((txId == null) ? 0 : txId.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + + AmqpTransactionId other = (AmqpTransactionId) obj; + if (txId == null) { + if (other.txId != null) { + return false; + } + } + else if (!txId.equals(other.txId)) { + return false; + } + + return true; + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpTransferTagGenerator.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpTransferTagGenerator.java new file mode 100644 index 0000000000..85ee07f0e4 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpTransferTagGenerator.java @@ -0,0 +1,104 @@ +/** + * 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 org.apache.activemq.transport.amqp.client; + +import java.io.UnsupportedEncodingException; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * Utility class that can generate and if enabled pool the binary tag values + * used to identify transfers over an AMQP link. + */ +public final class AmqpTransferTagGenerator { + + public static final int DEFAULT_TAG_POOL_SIZE = 1024; + + private long nextTagId; + private int maxPoolSize = DEFAULT_TAG_POOL_SIZE; + + private final Set tagPool; + + public AmqpTransferTagGenerator() { + this(false); + } + + public AmqpTransferTagGenerator(boolean pool) { + if (pool) { + this.tagPool = new LinkedHashSet<>(); + } + else { + this.tagPool = null; + } + } + + /** + * Retrieves the next available tag. + * + * @return a new or unused tag depending on the pool option. + */ + public byte[] getNextTag() { + byte[] rc; + if (tagPool != null && !tagPool.isEmpty()) { + final Iterator iterator = tagPool.iterator(); + rc = iterator.next(); + iterator.remove(); + } + else { + try { + rc = Long.toHexString(nextTagId++).getBytes("UTF-8"); + } + catch (UnsupportedEncodingException e) { + // This should never happen since we control the input. + throw new RuntimeException(e); + } + } + return rc; + } + + /** + * When used as a pooled cache of tags the unused tags should always be returned once + * the transfer has been settled. + * + * @param data a previously borrowed tag that is no longer in use. + */ + public void returnTag(byte[] data) { + if (tagPool != null && tagPool.size() < maxPoolSize) { + tagPool.add(data); + } + } + + /** + * Gets the current max pool size value. + * + * @return the current max tag pool size. + */ + public int getMaxPoolSize() { + return maxPoolSize; + } + + /** + * Sets the max tag pool size. If the size is smaller than the current number + * of pooled tags the pool will drain over time until it matches the max. + * + * @param maxPoolSize the maximum number of tags to hold in the pool. + */ + public void setMaxPoolSize(int maxPoolSize) { + this.maxPoolSize = maxPoolSize; + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpUnknownFilterType.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpUnknownFilterType.java new file mode 100644 index 0000000000..8a4ce6b7f7 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpUnknownFilterType.java @@ -0,0 +1,49 @@ +/** + * 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 org.apache.activemq.transport.amqp.client; + +import org.apache.qpid.proton.amqp.DescribedType; +import org.apache.qpid.proton.amqp.Symbol; +import org.apache.qpid.proton.amqp.UnsignedLong; + +/** + * A Described Type wrapper for an unsupported filter that the broker should ignore. + */ +public class AmqpUnknownFilterType implements DescribedType { + + public static final AmqpUnknownFilterType UNKOWN_FILTER = new AmqpUnknownFilterType(); + + public static final UnsignedLong UNKNOWN_FILTER_CODE = UnsignedLong.valueOf(0x0000468C00000099L); + public static final Symbol UNKNOWN_FILTER_NAME = Symbol.valueOf("apache.org:unkown-filter:string"); + public static final Object[] UNKNOWN_FILTER_IDS = new Object[]{UNKNOWN_FILTER_CODE, UNKNOWN_FILTER_NAME}; + + private final String payload; + + public AmqpUnknownFilterType() { + this.payload = "UnknownFilter{}"; + } + + @Override + public Object getDescriptor() { + return UNKNOWN_FILTER_CODE; + } + + @Override + public Object getDescribed() { + return this.payload; + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpValidator.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpValidator.java new file mode 100644 index 0000000000..5f46cb6068 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/AmqpValidator.java @@ -0,0 +1,101 @@ +/** + * 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 org.apache.activemq.transport.amqp.client; + +import org.apache.qpid.proton.engine.Connection; +import org.apache.qpid.proton.engine.Receiver; +import org.apache.qpid.proton.engine.Sender; +import org.apache.qpid.proton.engine.Session; + +/** + * Abstract base for a validation hook that is used in tests to check + * the state of a remote resource after a variety of lifecycle events. + */ +public class AmqpValidator { + + private boolean valid = true; + private String errorMessage; + + public void inspectOpenedResource(Connection connection) { + + } + + public void inspectOpenedResource(Session session) { + + } + + public void inspectOpenedResource(Sender sender) { + + } + + public void inspectOpenedResource(Receiver receiver) { + + } + + public void inspectClosedResource(Connection remoteConnection) { + + } + + public void inspectClosedResource(Session session) { + + } + + public void inspectClosedResource(Sender sender) { + + } + + public void inspectClosedResource(Receiver receiver) { + + } + + public void inspectDetachedResource(Sender sender) { + + } + + public void inspectDetachedResource(Receiver receiver) { + + } + + public boolean isValid() { + return valid; + } + + protected void setValid(boolean valid) { + this.valid = valid; + } + + public String getErrorMessage() { + return errorMessage; + } + + protected void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + protected void markAsInvalid(String errorMessage) { + if (valid) { + setValid(false); + setErrorMessage(errorMessage); + } + } + + public void assertValid() { + if (!isValid()) { + throw new AssertionError(errorMessage); + } + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/sasl/AbstractMechanism.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/sasl/AbstractMechanism.java new file mode 100644 index 0000000000..011fba745d --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/sasl/AbstractMechanism.java @@ -0,0 +1,97 @@ +/** + * 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 org.apache.activemq.transport.amqp.client.sasl; + +import java.util.HashMap; +import java.util.Map; + +/** + * Base class for SASL Authentication Mechanism that implements the basic + * methods of a Mechanism class. + */ +public abstract class AbstractMechanism implements Mechanism { + + protected static final byte[] EMPTY = new byte[0]; + + private String username; + private String password; + private String authzid; + private Map properties = new HashMap<>(); + + @Override + public int compareTo(Mechanism other) { + + if (getPriority() < other.getPriority()) { + return -1; + } + else if (getPriority() > other.getPriority()) { + return 1; + } + + return 0; + } + + @Override + public void setUsername(String value) { + this.username = value; + } + + @Override + public String getUsername() { + return username; + } + + @Override + public void setPassword(String value) { + this.password = value; + } + + @Override + public String getPassword() { + return this.password; + } + + @Override + public void setProperties(Map properties) { + this.properties = properties; + } + + @Override + public Map getProperties() { + return this.properties; + } + + @Override + public String toString() { + return "SASL-" + getName(); + } + + @Override + public String getAuthzid() { + return authzid; + } + + @Override + public void setAuthzid(String authzid) { + this.authzid = authzid; + } + + @Override + public boolean isApplicable(String username, String password) { + return true; + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/sasl/AnonymousMechanism.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/sasl/AnonymousMechanism.java new file mode 100644 index 0000000000..c3d36aa210 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/sasl/AnonymousMechanism.java @@ -0,0 +1,43 @@ +/** + * 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 org.apache.activemq.transport.amqp.client.sasl; + +/** + * Implements the Anonymous SASL authentication mechanism. + */ +public class AnonymousMechanism extends AbstractMechanism { + + @Override + public byte[] getInitialResponse() { + return EMPTY; + } + + @Override + public byte[] getChallengeResponse(byte[] challenge) { + return EMPTY; + } + + @Override + public int getPriority() { + return PRIORITY.LOWEST.getValue(); + } + + @Override + public String getName() { + return "ANONYMOUS"; + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/sasl/CramMD5Mechanism.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/sasl/CramMD5Mechanism.java new file mode 100644 index 0000000000..4821314f24 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/sasl/CramMD5Mechanism.java @@ -0,0 +1,94 @@ +/** + * 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 org.apache.activemq.transport.amqp.client.sasl; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import javax.security.sasl.SaslException; +import java.io.UnsupportedEncodingException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; + +/** + * Implements the SASL PLAIN authentication Mechanism. + * + * User name and Password values are sent without being encrypted. + */ +public class CramMD5Mechanism extends AbstractMechanism { + + private static final String ASCII = "ASCII"; + private static final String HMACMD5 = "HMACMD5"; + private boolean sentResponse; + + @Override + public int getPriority() { + return PRIORITY.HIGH.getValue(); + } + + @Override + public String getName() { + return "CRAM-MD5"; + } + + @Override + public byte[] getInitialResponse() { + return EMPTY; + } + + @Override + public byte[] getChallengeResponse(byte[] challenge) throws SaslException { + if (!sentResponse && challenge != null && challenge.length != 0) { + try { + SecretKeySpec key = new SecretKeySpec(getPassword().getBytes(ASCII), HMACMD5); + Mac mac = Mac.getInstance(HMACMD5); + mac.init(key); + + byte[] bytes = mac.doFinal(challenge); + + StringBuffer hash = new StringBuffer(getUsername()); + hash.append(' '); + for (int i = 0; i < bytes.length; i++) { + String hex = Integer.toHexString(0xFF & bytes[i]); + if (hex.length() == 1) { + hash.append('0'); + } + hash.append(hex); + } + + sentResponse = true; + return hash.toString().getBytes(ASCII); + } + catch (UnsupportedEncodingException e) { + throw new SaslException("Unable to utilise required encoding", e); + } + catch (InvalidKeyException e) { + throw new SaslException("Unable to utilise key", e); + } + catch (NoSuchAlgorithmException e) { + throw new SaslException("Unable to utilise required algorithm", e); + } + } + else { + return EMPTY; + } + } + + @Override + public boolean isApplicable(String username, String password) { + return username != null && username.length() > 0 && password != null && password.length() > 0; + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/sasl/Mechanism.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/sasl/Mechanism.java new file mode 100644 index 0000000000..a79406f717 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/sasl/Mechanism.java @@ -0,0 +1,143 @@ +/** + * 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 org.apache.activemq.transport.amqp.client.sasl; + +import javax.security.sasl.SaslException; +import java.util.Map; + +/** + * Interface for all SASL authentication mechanism implementations. + */ +public interface Mechanism extends Comparable { + + /** + * Relative priority values used to arrange the found SASL + * mechanisms in a preferred order where the level of security + * generally defines the preference. + */ + enum PRIORITY { + LOWEST(0), + LOW(1), + MEDIUM(2), + HIGH(3), + HIGHEST(4); + + private final int value; + + PRIORITY(int value) { + this.value = value; + } + + public int getValue() { + return value; + } + }; + + /** + * @return return the relative priority of this SASL mechanism. + */ + int getPriority(); + + /** + * @return the well known name of this SASL mechanism. + */ + String getName(); + + /** + * @return the response buffer used to answer the initial SASL cycle. + * @throws SaslException if an error occurs computing the response. + */ + byte[] getInitialResponse() throws SaslException; + + /** + * Create a response based on a given challenge from the remote peer. + * + * @param challenge the challenge that this Mechanism should response to. + * @return the response that answers the given challenge. + * @throws SaslException if an error occurs computing the response. + */ + byte[] getChallengeResponse(byte[] challenge) throws SaslException; + + /** + * Sets the user name value for this Mechanism. The Mechanism can ignore this + * value if it does not utilize user name in it's authentication processing. + * + * @param username The user name given. + */ + void setUsername(String value); + + /** + * Returns the configured user name value for this Mechanism. + * + * @return the currently set user name value for this Mechanism. + */ + String getUsername(); + + /** + * Sets the password value for this Mechanism. The Mechanism can ignore this + * value if it does not utilize a password in it's authentication processing. + * + * @param username The user name given. + */ + void setPassword(String value); + + /** + * Returns the configured password value for this Mechanism. + * + * @return the currently set password value for this Mechanism. + */ + String getPassword(); + + /** + * Sets any additional Mechanism specific properties using a Map + * + * @param options the map of additional properties that this Mechanism should utilize. + */ + void setProperties(Map options); + + /** + * The currently set Properties for this Mechanism. + * + * @return the current set of configuration Properties for this Mechanism. + */ + Map getProperties(); + + /** + * Using the configured credentials, check if the mechanism applies or not. + * + * @param username The user name that will be used with this mechanism + * @param password The password that will be used with this mechanism + * @return true if the mechanism works with the provided credentials or not. + */ + boolean isApplicable(String username, String password); + + /** + * Get the currently configured Authentication ID. + * + * @return the currently set Authentication ID. + */ + String getAuthzid(); + + /** + * Sets an Authentication ID that some mechanism can use during the + * challenge response phase. + * + * @param authzid The Authentication ID to use. + */ + void setAuthzid(String authzid); + +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/sasl/PlainMechanism.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/sasl/PlainMechanism.java new file mode 100644 index 0000000000..d9b3ba3b92 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/sasl/PlainMechanism.java @@ -0,0 +1,76 @@ +/** + * 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 org.apache.activemq.transport.amqp.client.sasl; + +/** + * Implements the SASL PLAIN authentication Mechanism. + * + * User name and Password values are sent without being encrypted. + */ +public class PlainMechanism extends AbstractMechanism { + + public static final String MECH_NAME = "PLAIN"; + + @Override + public int getPriority() { + return PRIORITY.MEDIUM.getValue(); + } + + @Override + public String getName() { + return MECH_NAME; + } + + @Override + public byte[] getInitialResponse() { + + String authzid = getAuthzid(); + String username = getUsername(); + String password = getPassword(); + + if (authzid == null) { + authzid = ""; + } + + if (username == null) { + username = ""; + } + + if (password == null) { + password = ""; + } + + byte[] authzidBytes = authzid.getBytes(); + byte[] usernameBytes = username.getBytes(); + byte[] passwordBytes = password.getBytes(); + byte[] data = new byte[authzidBytes.length + 1 + usernameBytes.length + 1 + passwordBytes.length]; + System.arraycopy(authzidBytes, 0, data, 0, authzidBytes.length); + System.arraycopy(usernameBytes, 0, data, 1 + authzidBytes.length, usernameBytes.length); + System.arraycopy(passwordBytes, 0, data, 2 + authzidBytes.length + usernameBytes.length, passwordBytes.length); + return data; + } + + @Override + public byte[] getChallengeResponse(byte[] challenge) { + return EMPTY; + } + + @Override + public boolean isApplicable(String username, String password) { + return username != null && username.length() > 0 && password != null && password.length() > 0; + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/sasl/SaslAuthenticator.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/sasl/SaslAuthenticator.java new file mode 100644 index 0000000000..5c25fae229 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/sasl/SaslAuthenticator.java @@ -0,0 +1,182 @@ +/** + * 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 org.apache.activemq.transport.amqp.client.sasl; + +import javax.security.sasl.SaslException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.apache.qpid.proton.engine.Sasl; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Manage the SASL authentication process + */ +public class SaslAuthenticator { + + private static final Logger LOG = LoggerFactory.getLogger(SaslAuthenticator.class); + + private final Sasl sasl; + private final String username; + private final String password; + private final String authzid; + private Mechanism mechanism; + private String mechanismRestriction; + + /** + * Create the authenticator and initialize it. + * + * @param sasl The Proton SASL entry point this class will use to manage the authentication. + * @param username The user name that will be used to authenticate. + * @param password The password that will be used to authenticate. + * @param authzid The authzid used when authenticating (currently only with PLAIN) + * @param mechanismRestriction A particular mechanism to use (if offered by the server) or null to allow selection. + */ + public SaslAuthenticator(Sasl sasl, String username, String password, String authzid, String mechanismRestriction) { + this.sasl = sasl; + this.username = username; + this.password = password; + this.authzid = authzid; + this.mechanismRestriction = mechanismRestriction; + } + + /** + * Process the SASL authentication cycle until such time as an outcome is determine. This + * method must be called by the managing entity until the return value is true indicating a + * successful authentication or a JMSSecurityException is thrown indicating that the + * handshake failed. + * + * @throws SecurityException + */ + public boolean authenticate() throws SecurityException { + switch (sasl.getState()) { + case PN_SASL_IDLE: + handleSaslInit(); + break; + case PN_SASL_STEP: + handleSaslStep(); + break; + case PN_SASL_FAIL: + handleSaslFail(); + break; + case PN_SASL_PASS: + return true; + default: + } + + return false; + } + + private void handleSaslInit() throws SecurityException { + try { + String[] remoteMechanisms = sasl.getRemoteMechanisms(); + if (remoteMechanisms != null && remoteMechanisms.length != 0) { + mechanism = findMatchingMechanism(remoteMechanisms); + if (mechanism != null) { + mechanism.setUsername(username); + mechanism.setPassword(password); + mechanism.setAuthzid(authzid); + // TODO - set additional options from URI. + // TODO - set a host value. + + sasl.setMechanisms(mechanism.getName()); + byte[] response = mechanism.getInitialResponse(); + if (response != null && response.length != 0) { + sasl.send(response, 0, response.length); + } + } + else { + // TODO - Better error message. + throw new SecurityException("Could not find a matching SASL mechanism for the remote peer."); + } + } + } + catch (SaslException se) { + // TODO - Better error message. + SecurityException jmsse = new SecurityException("Exception while processing SASL init."); + jmsse.initCause(se); + throw jmsse; + } + } + + private Mechanism findMatchingMechanism(String... remoteMechanisms) { + + Mechanism match = null; + List found = new ArrayList<>(); + + for (String remoteMechanism : remoteMechanisms) { + if (mechanismRestriction != null && !mechanismRestriction.equals(remoteMechanism)) { + LOG.debug("Skipping {} mechanism because it is not the configured mechanism restriction {}", remoteMechanism, mechanismRestriction); + continue; + } + + Mechanism mechanism = null; + if (remoteMechanism.equalsIgnoreCase("PLAIN")) { + mechanism = new PlainMechanism(); + } + else if (remoteMechanism.equalsIgnoreCase("ANONYMOUS")) { + mechanism = new AnonymousMechanism(); + } + else if (remoteMechanism.equalsIgnoreCase("CRAM-MD5")) { + mechanism = new CramMD5Mechanism(); + } + else { + LOG.debug("Unknown remote mechanism {}, skipping", remoteMechanism); + continue; + } + + if (mechanism.isApplicable(username, password)) { + found.add(mechanism); + } + } + + if (!found.isEmpty()) { + // Sorts by priority using Mechanism comparison and return the last value in + // list which is the Mechanism deemed to be the highest priority match. + Collections.sort(found); + match = found.get(found.size() - 1); + } + + LOG.info("Best match for SASL auth was: {}", match); + + return match; + } + + private void handleSaslStep() throws SecurityException { + try { + if (sasl.pending() != 0) { + byte[] challenge = new byte[sasl.pending()]; + sasl.recv(challenge, 0, challenge.length); + byte[] response = mechanism.getChallengeResponse(challenge); + sasl.send(response, 0, response.length); + } + } + catch (SaslException se) { + // TODO - Better error message. + SecurityException jmsse = new SecurityException("Exception while processing SASL step."); + jmsse.initCause(se); + throw jmsse; + } + } + + private void handleSaslFail() throws SecurityException { + // TODO - Better error message. + throw new SecurityException("Client failed to authenticate"); + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/transport/NettyTcpTransport.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/transport/NettyTcpTransport.java new file mode 100644 index 0000000000..f790433a25 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/transport/NettyTcpTransport.java @@ -0,0 +1,402 @@ +/** + * 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 org.apache.activemq.transport.amqp.client.transport; + +import java.io.IOException; +import java.net.URI; +import java.security.Principal; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import io.netty.bootstrap.Bootstrap; +import io.netty.buffer.ByteBuf; +import io.netty.channel.Channel; +import io.netty.channel.ChannelFuture; +import io.netty.channel.ChannelFutureListener; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.ChannelOption; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.FixedRecvByteBufAllocator; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.socket.nio.NioSocketChannel; +import io.netty.handler.ssl.SslHandler; +import io.netty.util.concurrent.Future; +import io.netty.util.concurrent.GenericFutureListener; +import org.apache.activemq.transport.amqp.client.util.IOExceptionSupport; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * TCP based transport that uses Netty as the underlying IO layer. + */ +public class NettyTcpTransport implements NettyTransport { + + private static final Logger LOG = LoggerFactory.getLogger(NettyTcpTransport.class); + + private static final int QUIET_PERIOD = 20; + private static final int SHUTDOWN_TIMEOUT = 100; + + protected Bootstrap bootstrap; + protected EventLoopGroup group; + protected Channel channel; + protected NettyTransportListener listener; + protected NettyTransportOptions options; + protected final URI remote; + protected boolean secure; + + private final AtomicBoolean connected = new AtomicBoolean(); + private final AtomicBoolean closed = new AtomicBoolean(); + private final CountDownLatch connectLatch = new CountDownLatch(1); + private IOException failureCause; + private Throwable pendingFailure; + + /** + * Create a new transport instance + * + * @param remoteLocation the URI that defines the remote resource to connect to. + * @param options the transport options used to configure the socket connection. + */ + public NettyTcpTransport(URI remoteLocation, NettyTransportOptions options) { + this(null, remoteLocation, options); + } + + /** + * Create a new transport instance + * + * @param listener the TransportListener that will receive events from this Transport. + * @param remoteLocation the URI that defines the remote resource to connect to. + * @param options the transport options used to configure the socket connection. + */ + public NettyTcpTransport(NettyTransportListener listener, URI remoteLocation, NettyTransportOptions options) { + this.options = options; + this.listener = listener; + this.remote = remoteLocation; + this.secure = remoteLocation.getScheme().equalsIgnoreCase("ssl"); + } + + @Override + public void connect() throws IOException { + + if (listener == null) { + throw new IllegalStateException("A transport listener must be set before connection attempts."); + } + + group = new NioEventLoopGroup(1); + + bootstrap = new Bootstrap(); + bootstrap.group(group); + bootstrap.channel(NioSocketChannel.class); + bootstrap.handler(new ChannelInitializer() { + + @Override + public void initChannel(Channel connectedChannel) throws Exception { + configureChannel(connectedChannel); + } + }); + + configureNetty(bootstrap, getTransportOptions()); + + ChannelFuture future = bootstrap.connect(getRemoteHost(), getRemotePort()); + future.addListener(new ChannelFutureListener() { + + @Override + public void operationComplete(ChannelFuture future) throws Exception { + if (future.isSuccess()) { + handleConnected(future.channel()); + } + else if (future.isCancelled()) { + connectionFailed(future.channel(), new IOException("Connection attempt was cancelled")); + } + else { + connectionFailed(future.channel(), IOExceptionSupport.create(future.cause())); + } + } + }); + + try { + connectLatch.await(); + } + catch (InterruptedException ex) { + LOG.debug("Transport connection was interrupted."); + Thread.interrupted(); + failureCause = IOExceptionSupport.create(ex); + } + + if (failureCause != null) { + // Close out any Netty resources now as they are no longer needed. + if (channel != null) { + channel.close().syncUninterruptibly(); + channel = null; + } + if (group != null) { + group.shutdownGracefully(QUIET_PERIOD, SHUTDOWN_TIMEOUT, TimeUnit.MILLISECONDS); + group = null; + } + + throw failureCause; + } + else { + // Connected, allow any held async error to fire now and close the transport. + channel.eventLoop().execute(new Runnable() { + + @Override + public void run() { + if (pendingFailure != null) { + channel.pipeline().fireExceptionCaught(pendingFailure); + } + } + }); + } + } + + @Override + public boolean isConnected() { + return connected.get(); + } + + @Override + public boolean isSSL() { + return secure; + } + + @Override + public void close() throws IOException { + if (closed.compareAndSet(false, true)) { + connected.set(false); + if (channel != null) { + channel.close().syncUninterruptibly(); + } + if (group != null) { + group.shutdownGracefully(QUIET_PERIOD, SHUTDOWN_TIMEOUT, TimeUnit.MILLISECONDS); + } + } + } + + @Override + public ByteBuf allocateSendBuffer(int size) throws IOException { + checkConnected(); + return channel.alloc().ioBuffer(size, size); + } + + @Override + public void send(ByteBuf output) throws IOException { + checkConnected(); + int length = output.readableBytes(); + if (length == 0) { + return; + } + + LOG.trace("Attempted write of: {} bytes", length); + + channel.writeAndFlush(output); + } + + @Override + public NettyTransportListener getTransportListener() { + return listener; + } + + @Override + public void setTransportListener(NettyTransportListener listener) { + this.listener = listener; + } + + @Override + public NettyTransportOptions getTransportOptions() { + if (options == null) { + if (isSSL()) { + options = NettyTransportSslOptions.INSTANCE; + } + else { + options = NettyTransportOptions.INSTANCE; + } + } + + return options; + } + + @Override + public URI getRemoteLocation() { + return remote; + } + + @Override + public Principal getLocalPrincipal() { + if (!isSSL()) { + throw new UnsupportedOperationException("Not connected to a secure channel"); + } + + SslHandler sslHandler = channel.pipeline().get(SslHandler.class); + + return sslHandler.engine().getSession().getLocalPrincipal(); + } + + //----- Internal implementation details, can be overridden as needed --// + + protected String getRemoteHost() { + return remote.getHost(); + } + + protected int getRemotePort() { + int port = remote.getPort(); + + if (port <= 0) { + if (isSSL()) { + port = getSslOptions().getDefaultSslPort(); + } + else { + port = getTransportOptions().getDefaultTcpPort(); + } + } + + return port; + } + + protected void configureNetty(Bootstrap bootstrap, NettyTransportOptions options) { + bootstrap.option(ChannelOption.TCP_NODELAY, options.isTcpNoDelay()); + bootstrap.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, options.getConnectTimeout()); + bootstrap.option(ChannelOption.SO_KEEPALIVE, options.isTcpKeepAlive()); + bootstrap.option(ChannelOption.SO_LINGER, options.getSoLinger()); + bootstrap.option(ChannelOption.ALLOCATOR, PartialPooledByteBufAllocator.INSTANCE); + + if (options.getSendBufferSize() != -1) { + bootstrap.option(ChannelOption.SO_SNDBUF, options.getSendBufferSize()); + } + + if (options.getReceiveBufferSize() != -1) { + bootstrap.option(ChannelOption.SO_RCVBUF, options.getReceiveBufferSize()); + bootstrap.option(ChannelOption.RCVBUF_ALLOCATOR, new FixedRecvByteBufAllocator(options.getReceiveBufferSize())); + } + + if (options.getTrafficClass() != -1) { + bootstrap.option(ChannelOption.IP_TOS, options.getTrafficClass()); + } + } + + protected void configureChannel(final Channel channel) throws Exception { + if (isSSL()) { + SslHandler sslHandler = NettyTransportSupport.createSslHandler(getRemoteLocation(), getSslOptions()); + sslHandler.handshakeFuture().addListener(new GenericFutureListener>() { + @Override + public void operationComplete(Future future) throws Exception { + if (future.isSuccess()) { + LOG.trace("SSL Handshake has completed: {}", channel); + connectionEstablished(channel); + } + else { + LOG.trace("SSL Handshake has failed: {}", channel); + connectionFailed(channel, IOExceptionSupport.create(future.cause())); + } + } + }); + + channel.pipeline().addLast(sslHandler); + } + + channel.pipeline().addLast(new NettyTcpTransportHandler()); + } + + protected void handleConnected(final Channel channel) throws Exception { + if (!isSSL()) { + connectionEstablished(channel); + } + } + + //----- State change handlers and checks ---------------------------------// + + /** + * Called when the transport has successfully connected and is ready for use. + */ + protected void connectionEstablished(Channel connectedChannel) { + channel = connectedChannel; + connected.set(true); + connectLatch.countDown(); + } + + /** + * Called when the transport connection failed and an error should be returned. + * + * @param failedChannel The Channel instance that failed. + * @param cause An IOException that describes the cause of the failed connection. + */ + protected void connectionFailed(Channel failedChannel, IOException cause) { + failureCause = IOExceptionSupport.create(cause); + channel = failedChannel; + connected.set(false); + connectLatch.countDown(); + } + + private NettyTransportSslOptions getSslOptions() { + return (NettyTransportSslOptions) getTransportOptions(); + } + + private void checkConnected() throws IOException { + if (!connected.get()) { + throw new IOException("Cannot send to a non-connected transport."); + } + } + + //----- Handle connection events -----------------------------------------// + + private class NettyTcpTransportHandler extends SimpleChannelInboundHandler { + + @Override + public void channelActive(ChannelHandlerContext context) throws Exception { + LOG.trace("Channel has become active! Channel is {}", context.channel()); + } + + @Override + public void channelInactive(ChannelHandlerContext context) throws Exception { + LOG.trace("Channel has gone inactive! Channel is {}", context.channel()); + if (connected.compareAndSet(true, false) && !closed.get()) { + LOG.trace("Firing onTransportClosed listener"); + listener.onTransportClosed(); + } + } + + @Override + public void exceptionCaught(ChannelHandlerContext context, Throwable cause) throws Exception { + LOG.trace("Exception on channel! Channel is {}", context.channel()); + if (connected.compareAndSet(true, false) && !closed.get()) { + LOG.trace("Firing onTransportError listener"); + if (pendingFailure != null) { + listener.onTransportError(pendingFailure); + } + else { + listener.onTransportError(cause); + } + } + else { + // Hold the first failure for later dispatch if connect succeeds. + // This will then trigger disconnect using the first error reported. + if (pendingFailure != null) { + LOG.trace("Holding error until connect succeeds: {}", cause.getMessage()); + pendingFailure = cause; + } + } + } + + @Override + protected void channelRead0(ChannelHandlerContext ctx, ByteBuf buffer) throws Exception { + LOG.trace("New data read: {} bytes incoming: {}", buffer.readableBytes(), buffer); + listener.onData(buffer); + } + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/transport/NettyTransport.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/transport/NettyTransport.java new file mode 100644 index 0000000000..a2bacdca78 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/transport/NettyTransport.java @@ -0,0 +1,52 @@ +/* + * 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 org.apache.activemq.transport.amqp.client.transport; + +import java.io.IOException; +import java.net.URI; +import java.security.Principal; + +import io.netty.buffer.ByteBuf; + +/** + * + */ +public interface NettyTransport { + + void connect() throws IOException; + + boolean isConnected(); + + boolean isSSL(); + + void close() throws IOException; + + ByteBuf allocateSendBuffer(int size) throws IOException; + + void send(ByteBuf output) throws IOException; + + NettyTransportListener getTransportListener(); + + void setTransportListener(NettyTransportListener listener); + + NettyTransportOptions getTransportOptions(); + + URI getRemoteLocation(); + + Principal getLocalPrincipal(); + +} \ No newline at end of file diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/transport/NettyTransportFactory.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/transport/NettyTransportFactory.java new file mode 100644 index 0000000000..566371303d --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/transport/NettyTransportFactory.java @@ -0,0 +1,80 @@ +/** + * 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 org.apache.activemq.transport.amqp.client.transport; + +import java.net.URI; +import java.util.Map; + +import org.apache.activemq.transport.amqp.client.util.PropertyUtil; + +/** + * Factory for creating the Netty based TCP Transport. + */ +public final class NettyTransportFactory { + + private NettyTransportFactory() { + } + + /** + * Creates an instance of the given Transport and configures it using the + * properties set on the given remote broker URI. + * + * @param remoteURI The URI used to connect to a remote Peer. + * @return a new Transport instance. + * @throws Exception if an error occurs while creating the Transport instance. + */ + public static NettyTransport createTransport(URI remoteURI) throws Exception { + Map map = PropertyUtil.parseQuery(remoteURI.getQuery()); + Map transportURIOptions = PropertyUtil.filterProperties(map, "transport."); + NettyTransportOptions transportOptions = null; + + remoteURI = PropertyUtil.replaceQuery(remoteURI, map); + + if (!remoteURI.getScheme().equalsIgnoreCase("ssl") && !remoteURI.getScheme().equalsIgnoreCase("wss")) { + transportOptions = NettyTransportOptions.INSTANCE.clone(); + } + else { + transportOptions = NettyTransportSslOptions.INSTANCE.clone(); + } + + Map unused = PropertyUtil.setProperties(transportOptions, transportURIOptions); + if (!unused.isEmpty()) { + String msg = " Not all transport options could be set on the TCP based" + + " Transport. Check the options are spelled correctly." + + " Unused parameters=[" + unused + "]." + + " This provider instance cannot be started."; + throw new IllegalArgumentException(msg); + } + + NettyTransport result = null; + + switch (remoteURI.getScheme().toLowerCase()) { + case "tcp": + case "ssl": + result = new NettyTcpTransport(remoteURI, transportOptions); + break; + case "ws": + case "wss": + result = new NettyWSTransport(remoteURI, transportOptions); + break; + default: + throw new IllegalArgumentException("Invalid URI Scheme: " + remoteURI.getScheme()); + } + + return result; + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/transport/NettyTransportListener.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/transport/NettyTransportListener.java new file mode 100644 index 0000000000..c23ca8c714 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/transport/NettyTransportListener.java @@ -0,0 +1,46 @@ +/** + * 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 org.apache.activemq.transport.amqp.client.transport; + +import io.netty.buffer.ByteBuf; + +/** + * Listener interface that should be implemented by users of the various + * QpidJMS Transport classes. + */ +public interface NettyTransportListener { + + /** + * Called when new incoming data has become available. + * + * @param incoming the next incoming packet of data. + */ + void onData(ByteBuf incoming); + + /** + * Called if the connection state becomes closed. + */ + void onTransportClosed(); + + /** + * Called when an error occurs during normal Transport operations. + * + * @param cause the error that triggered this event. + */ + void onTransportError(Throwable cause); + +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/transport/NettyTransportOptions.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/transport/NettyTransportOptions.java new file mode 100644 index 0000000000..3ffb8c8d22 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/transport/NettyTransportOptions.java @@ -0,0 +1,177 @@ +/** + * 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 org.apache.activemq.transport.amqp.client.transport; + +/** + * Encapsulates all the TCP Transport options in one configuration object. + */ +public class NettyTransportOptions implements Cloneable { + + public static final int DEFAULT_SEND_BUFFER_SIZE = 64 * 1024; + public static final int DEFAULT_RECEIVE_BUFFER_SIZE = DEFAULT_SEND_BUFFER_SIZE; + public static final int DEFAULT_TRAFFIC_CLASS = 0; + public static final boolean DEFAULT_TCP_NO_DELAY = true; + public static final boolean DEFAULT_TCP_KEEP_ALIVE = false; + public static final int DEFAULT_SO_LINGER = Integer.MIN_VALUE; + public static final int DEFAULT_SO_TIMEOUT = -1; + public static final int DEFAULT_CONNECT_TIMEOUT = 60000; + public static final int DEFAULT_TCP_PORT = 5672; + + public static final NettyTransportOptions INSTANCE = new NettyTransportOptions(); + + private int sendBufferSize = DEFAULT_SEND_BUFFER_SIZE; + private int receiveBufferSize = DEFAULT_RECEIVE_BUFFER_SIZE; + private int trafficClass = DEFAULT_TRAFFIC_CLASS; + private int connectTimeout = DEFAULT_CONNECT_TIMEOUT; + private int soTimeout = DEFAULT_SO_TIMEOUT; + private int soLinger = DEFAULT_SO_LINGER; + private boolean tcpKeepAlive = DEFAULT_TCP_KEEP_ALIVE; + private boolean tcpNoDelay = DEFAULT_TCP_NO_DELAY; + private int defaultTcpPort = DEFAULT_TCP_PORT; + + /** + * @return the currently set send buffer size in bytes. + */ + public int getSendBufferSize() { + return sendBufferSize; + } + + /** + * Sets the send buffer size in bytes, the value must be greater than zero + * or an {@link IllegalArgumentException} will be thrown. + * + * @param sendBufferSize the new send buffer size for the TCP Transport. + * @throws IllegalArgumentException if the value given is not in the valid range. + */ + public void setSendBufferSize(int sendBufferSize) { + if (sendBufferSize <= 0) { + throw new IllegalArgumentException("The send buffer size must be > 0"); + } + + this.sendBufferSize = sendBufferSize; + } + + /** + * @return the currently configured receive buffer size in bytes. + */ + public int getReceiveBufferSize() { + return receiveBufferSize; + } + + /** + * Sets the receive buffer size in bytes, the value must be greater than zero + * or an {@link IllegalArgumentException} will be thrown. + * + * @param receiveBufferSize the new receive buffer size for the TCP Transport. + * @throws IllegalArgumentException if the value given is not in the valid range. + */ + public void setReceiveBufferSize(int receiveBufferSize) { + if (receiveBufferSize <= 0) { + throw new IllegalArgumentException("The send buffer size must be > 0"); + } + + this.receiveBufferSize = receiveBufferSize; + } + + /** + * @return the currently configured traffic class value. + */ + public int getTrafficClass() { + return trafficClass; + } + + /** + * Sets the traffic class value used by the TCP connection, valid + * range is between 0 and 255. + * + * @param trafficClass the new traffic class value. + * @throws IllegalArgumentException if the value given is not in the valid range. + */ + public void setTrafficClass(int trafficClass) { + if (trafficClass < 0 || trafficClass > 255) { + throw new IllegalArgumentException("Traffic class must be in the range [0..255]"); + } + + this.trafficClass = trafficClass; + } + + public int getSoTimeout() { + return soTimeout; + } + + public void setSoTimeout(int soTimeout) { + this.soTimeout = soTimeout; + } + + public boolean isTcpNoDelay() { + return tcpNoDelay; + } + + public void setTcpNoDelay(boolean tcpNoDelay) { + this.tcpNoDelay = tcpNoDelay; + } + + public int getSoLinger() { + return soLinger; + } + + public void setSoLinger(int soLinger) { + this.soLinger = soLinger; + } + + public boolean isTcpKeepAlive() { + return tcpKeepAlive; + } + + public void setTcpKeepAlive(boolean keepAlive) { + this.tcpKeepAlive = keepAlive; + } + + public int getConnectTimeout() { + return connectTimeout; + } + + public void setConnectTimeout(int connectTimeout) { + this.connectTimeout = connectTimeout; + } + + public int getDefaultTcpPort() { + return defaultTcpPort; + } + + public void setDefaultTcpPort(int defaultTcpPort) { + this.defaultTcpPort = defaultTcpPort; + } + + @Override + public NettyTransportOptions clone() { + return copyOptions(new NettyTransportOptions()); + } + + protected NettyTransportOptions copyOptions(NettyTransportOptions copy) { + copy.setConnectTimeout(getConnectTimeout()); + copy.setReceiveBufferSize(getReceiveBufferSize()); + copy.setSendBufferSize(getSendBufferSize()); + copy.setSoLinger(getSoLinger()); + copy.setSoTimeout(getSoTimeout()); + copy.setTcpKeepAlive(isTcpKeepAlive()); + copy.setTcpNoDelay(isTcpNoDelay()); + copy.setTrafficClass(getTrafficClass()); + + return copy; + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/transport/NettyTransportSslOptions.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/transport/NettyTransportSslOptions.java new file mode 100644 index 0000000000..e256fbba8a --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/transport/NettyTransportSslOptions.java @@ -0,0 +1,284 @@ +/** + * 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 org.apache.activemq.transport.amqp.client.transport; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * Holds the defined SSL options for connections that operate over a secure + * transport. Options are read from the environment and can be overridden by + * specifying them on the connection URI. + */ +public class NettyTransportSslOptions extends NettyTransportOptions { + + public static final String DEFAULT_STORE_TYPE = "jks"; + public static final String DEFAULT_CONTEXT_PROTOCOL = "TLS"; + public static final boolean DEFAULT_TRUST_ALL = false; + public static final boolean DEFAULT_VERIFY_HOST = false; + public static final List DEFAULT_DISABLED_PROTOCOLS = Collections.unmodifiableList(Arrays.asList(new String[]{"SSLv2Hello", "SSLv3"})); + public static final int DEFAULT_SSL_PORT = 5671; + + public static final NettyTransportSslOptions INSTANCE = new NettyTransportSslOptions(); + + private String keyStoreLocation; + private String keyStorePassword; + private String trustStoreLocation; + private String trustStorePassword; + private String storeType = DEFAULT_STORE_TYPE; + private String[] enabledCipherSuites; + private String[] disabledCipherSuites; + private String[] enabledProtocols; + private String[] disabledProtocols = DEFAULT_DISABLED_PROTOCOLS.toArray(new String[0]); + private String contextProtocol = DEFAULT_CONTEXT_PROTOCOL; + + private boolean trustAll = DEFAULT_TRUST_ALL; + private boolean verifyHost = DEFAULT_VERIFY_HOST; + private String keyAlias; + private int defaultSslPort = DEFAULT_SSL_PORT; + + static { + INSTANCE.setKeyStoreLocation(System.getProperty("javax.net.ssl.keyStore")); + INSTANCE.setKeyStorePassword(System.getProperty("javax.net.ssl.keyStorePassword")); + INSTANCE.setTrustStoreLocation(System.getProperty("javax.net.ssl.trustStore")); + INSTANCE.setTrustStorePassword(System.getProperty("javax.net.ssl.keyStorePassword")); + } + + /** + * @return the keyStoreLocation currently configured. + */ + public String getKeyStoreLocation() { + return keyStoreLocation; + } + + /** + * Sets the location on disk of the key store to use. + * + * @param keyStoreLocation the keyStoreLocation to use to create the key manager. + */ + public void setKeyStoreLocation(String keyStoreLocation) { + this.keyStoreLocation = keyStoreLocation; + } + + /** + * @return the keyStorePassword + */ + public String getKeyStorePassword() { + return keyStorePassword; + } + + /** + * @param keyStorePassword the keyStorePassword to set + */ + public void setKeyStorePassword(String keyStorePassword) { + this.keyStorePassword = keyStorePassword; + } + + /** + * @return the trustStoreLocation + */ + public String getTrustStoreLocation() { + return trustStoreLocation; + } + + /** + * @param trustStoreLocation the trustStoreLocation to set + */ + public void setTrustStoreLocation(String trustStoreLocation) { + this.trustStoreLocation = trustStoreLocation; + } + + /** + * @return the trustStorePassword + */ + public String getTrustStorePassword() { + return trustStorePassword; + } + + /** + * @param trustStorePassword the trustStorePassword to set + */ + public void setTrustStorePassword(String trustStorePassword) { + this.trustStorePassword = trustStorePassword; + } + + /** + * @return the storeType + */ + public String getStoreType() { + return storeType; + } + + /** + * @param storeType the format that the store files are encoded in. + */ + public void setStoreType(String storeType) { + this.storeType = storeType; + } + + /** + * @return the enabledCipherSuites + */ + public String[] getEnabledCipherSuites() { + return enabledCipherSuites; + } + + /** + * @param enabledCipherSuites the enabledCipherSuites to set + */ + public void setEnabledCipherSuites(String[] enabledCipherSuites) { + this.enabledCipherSuites = enabledCipherSuites; + } + + /** + * @return the disabledCipherSuites + */ + public String[] getDisabledCipherSuites() { + return disabledCipherSuites; + } + + /** + * @param disabledCipherSuites the disabledCipherSuites to set + */ + public void setDisabledCipherSuites(String[] disabledCipherSuites) { + this.disabledCipherSuites = disabledCipherSuites; + } + + /** + * @return the enabledProtocols or null if the defaults should be used + */ + public String[] getEnabledProtocols() { + return enabledProtocols; + } + + /** + * The protocols to be set as enabled. + * + * @param enabledProtocols the enabled protocols to set, or null if the defaults should be used. + */ + public void setEnabledProtocols(String[] enabledProtocols) { + this.enabledProtocols = enabledProtocols; + } + + /** + * @return the protocols to disable or null if none should be + */ + public String[] getDisabledProtocols() { + return disabledProtocols; + } + + /** + * The protocols to be disable. + * + * @param disabledProtocols the protocols to disable, or null if none should be. + */ + public void setDisabledProtocols(String[] disabledProtocols) { + this.disabledProtocols = disabledProtocols; + } + + /** + * @return the context protocol to use + */ + public String getContextProtocol() { + return contextProtocol; + } + + /** + * The protocol value to use when creating an SSLContext via + * SSLContext.getInstance(protocol). + * + * @param contextProtocol the context protocol to use. + */ + public void setContextProtocol(String contextProtocol) { + this.contextProtocol = contextProtocol; + } + + /** + * @return the trustAll + */ + public boolean isTrustAll() { + return trustAll; + } + + /** + * @param trustAll the trustAll to set + */ + public void setTrustAll(boolean trustAll) { + this.trustAll = trustAll; + } + + /** + * @return the verifyHost + */ + public boolean isVerifyHost() { + return verifyHost; + } + + /** + * @param verifyHost the verifyHost to set + */ + public void setVerifyHost(boolean verifyHost) { + this.verifyHost = verifyHost; + } + + /** + * @return the key alias + */ + public String getKeyAlias() { + return keyAlias; + } + + /** + * @param keyAlias the key alias to use + */ + public void setKeyAlias(String keyAlias) { + this.keyAlias = keyAlias; + } + + public int getDefaultSslPort() { + return defaultSslPort; + } + + public void setDefaultSslPort(int defaultSslPort) { + this.defaultSslPort = defaultSslPort; + } + + @Override + public NettyTransportSslOptions clone() { + return copyOptions(new NettyTransportSslOptions()); + } + + protected NettyTransportSslOptions copyOptions(NettyTransportSslOptions copy) { + super.copyOptions(copy); + + copy.setKeyStoreLocation(getKeyStoreLocation()); + copy.setKeyStorePassword(getKeyStorePassword()); + copy.setTrustStoreLocation(getTrustStoreLocation()); + copy.setTrustStorePassword(getTrustStorePassword()); + copy.setStoreType(getStoreType()); + copy.setEnabledCipherSuites(getEnabledCipherSuites()); + copy.setDisabledCipherSuites(getDisabledCipherSuites()); + copy.setEnabledProtocols(getEnabledProtocols()); + copy.setDisabledProtocols(getDisabledProtocols()); + copy.setTrustAll(isTrustAll()); + copy.setVerifyHost(isVerifyHost()); + copy.setKeyAlias(getKeyAlias()); + copy.setContextProtocol(getContextProtocol()); + return copy; + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/transport/NettyTransportSupport.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/transport/NettyTransportSupport.java new file mode 100644 index 0000000000..51cedea028 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/transport/NettyTransportSupport.java @@ -0,0 +1,288 @@ +/** + * 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 org.apache.activemq.transport.amqp.client.transport; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509ExtendedKeyManager; +import javax.net.ssl.X509TrustManager; +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.net.URI; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.SecureRandom; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import io.netty.handler.ssl.SslHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Static class that provides various utility methods used by Transport implementations. + */ +public class NettyTransportSupport { + + private static final Logger LOG = LoggerFactory.getLogger(NettyTransportSupport.class); + + /** + * Creates a Netty SslHandler instance for use in Transports that require + * an SSL encoder / decoder. + * + * @param remote The URI of the remote peer that the SslHandler will be used against. + * @param options The SSL options object to build the SslHandler instance from. + * @return a new SslHandler that is configured from the given options. + * @throws Exception if an error occurs while creating the SslHandler instance. + */ + public static SslHandler createSslHandler(URI remote, NettyTransportSslOptions options) throws Exception { + return new SslHandler(createSslEngine(remote, createSslContext(options), options)); + } + + /** + * Create a new SSLContext using the options specific in the given TransportSslOptions + * instance. + * + * @param options the configured options used to create the SSLContext. + * @return a new SSLContext instance. + * @throws Exception if an error occurs while creating the context. + */ + public static SSLContext createSslContext(NettyTransportSslOptions options) throws Exception { + try { + String contextProtocol = options.getContextProtocol(); + LOG.trace("Getting SSLContext instance using protocol: {}", contextProtocol); + + SSLContext context = SSLContext.getInstance(contextProtocol); + KeyManager[] keyMgrs = loadKeyManagers(options); + TrustManager[] trustManagers = loadTrustManagers(options); + + context.init(keyMgrs, trustManagers, new SecureRandom()); + return context; + } + catch (Exception e) { + LOG.error("Failed to create SSLContext: {}", e, e); + throw e; + } + } + + /** + * Create a new SSLEngine instance in client mode from the given SSLContext and + * TransportSslOptions instances. + * + * @param context the SSLContext to use when creating the engine. + * @param options the TransportSslOptions to use to configure the new SSLEngine. + * @return a new SSLEngine instance in client mode. + * @throws Exception if an error occurs while creating the new SSLEngine. + */ + public static SSLEngine createSslEngine(SSLContext context, NettyTransportSslOptions options) throws Exception { + return createSslEngine(null, context, options); + } + + /** + * Create a new SSLEngine instance in client mode from the given SSLContext and + * TransportSslOptions instances. + * + * @param remote the URI of the remote peer that will be used to initialize the engine, may be null if none should. + * @param context the SSLContext to use when creating the engine. + * @param options the TransportSslOptions to use to configure the new SSLEngine. + * @return a new SSLEngine instance in client mode. + * @throws Exception if an error occurs while creating the new SSLEngine. + */ + public static SSLEngine createSslEngine(URI remote, + SSLContext context, + NettyTransportSslOptions options) throws Exception { + SSLEngine engine = null; + if (remote == null) { + engine = context.createSSLEngine(); + } + else { + engine = context.createSSLEngine(remote.getHost(), remote.getPort()); + } + + engine.setEnabledProtocols(buildEnabledProtocols(engine, options)); + engine.setEnabledCipherSuites(buildEnabledCipherSuites(engine, options)); + engine.setUseClientMode(true); + + if (options.isVerifyHost()) { + SSLParameters sslParameters = engine.getSSLParameters(); + sslParameters.setEndpointIdentificationAlgorithm("HTTPS"); + engine.setSSLParameters(sslParameters); + } + + return engine; + } + + private static String[] buildEnabledProtocols(SSLEngine engine, NettyTransportSslOptions options) { + List enabledProtocols = new ArrayList<>(); + + if (options.getEnabledProtocols() != null) { + List configuredProtocols = Arrays.asList(options.getEnabledProtocols()); + LOG.trace("Configured protocols from transport options: {}", configuredProtocols); + enabledProtocols.addAll(configuredProtocols); + } + else { + List engineProtocols = Arrays.asList(engine.getEnabledProtocols()); + LOG.trace("Default protocols from the SSLEngine: {}", engineProtocols); + enabledProtocols.addAll(engineProtocols); + } + + String[] disabledProtocols = options.getDisabledProtocols(); + if (disabledProtocols != null) { + List disabled = Arrays.asList(disabledProtocols); + LOG.trace("Disabled protocols: {}", disabled); + enabledProtocols.removeAll(disabled); + } + + LOG.trace("Enabled protocols: {}", enabledProtocols); + + return enabledProtocols.toArray(new String[0]); + } + + private static String[] buildEnabledCipherSuites(SSLEngine engine, NettyTransportSslOptions options) { + List enabledCipherSuites = new ArrayList<>(); + + if (options.getEnabledCipherSuites() != null) { + List configuredCipherSuites = Arrays.asList(options.getEnabledCipherSuites()); + LOG.trace("Configured cipher suites from transport options: {}", configuredCipherSuites); + enabledCipherSuites.addAll(configuredCipherSuites); + } + else { + List engineCipherSuites = Arrays.asList(engine.getEnabledCipherSuites()); + LOG.trace("Default cipher suites from the SSLEngine: {}", engineCipherSuites); + enabledCipherSuites.addAll(engineCipherSuites); + } + + String[] disabledCipherSuites = options.getDisabledCipherSuites(); + if (disabledCipherSuites != null) { + List disabled = Arrays.asList(disabledCipherSuites); + LOG.trace("Disabled cipher suites: {}", disabled); + enabledCipherSuites.removeAll(disabled); + } + + LOG.trace("Enabled cipher suites: {}", enabledCipherSuites); + + return enabledCipherSuites.toArray(new String[0]); + } + + private static TrustManager[] loadTrustManagers(NettyTransportSslOptions options) throws Exception { + if (options.isTrustAll()) { + return new TrustManager[]{createTrustAllTrustManager()}; + } + + if (options.getTrustStoreLocation() == null) { + return null; + } + + TrustManagerFactory fact = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + + String storeLocation = options.getTrustStoreLocation(); + String storePassword = options.getTrustStorePassword(); + String storeType = options.getStoreType(); + + LOG.trace("Attempt to load TrustStore from location {} of type {}", storeLocation, storeType); + + KeyStore trustStore = loadStore(storeLocation, storePassword, storeType); + fact.init(trustStore); + + return fact.getTrustManagers(); + } + + private static KeyManager[] loadKeyManagers(NettyTransportSslOptions options) throws Exception { + if (options.getKeyStoreLocation() == null) { + return null; + } + + KeyManagerFactory fact = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + + String storeLocation = options.getKeyStoreLocation(); + String storePassword = options.getKeyStorePassword(); + String storeType = options.getStoreType(); + String alias = options.getKeyAlias(); + + LOG.trace("Attempt to load KeyStore from location {} of type {}", storeLocation, storeType); + + KeyStore keyStore = loadStore(storeLocation, storePassword, storeType); + fact.init(keyStore, storePassword != null ? storePassword.toCharArray() : null); + + if (alias == null) { + return fact.getKeyManagers(); + } + else { + validateAlias(keyStore, alias); + return wrapKeyManagers(alias, fact.getKeyManagers()); + } + } + + private static KeyManager[] wrapKeyManagers(String alias, KeyManager[] origKeyManagers) { + KeyManager[] keyManagers = new KeyManager[origKeyManagers.length]; + for (int i = 0; i < origKeyManagers.length; i++) { + KeyManager km = origKeyManagers[i]; + if (km instanceof X509ExtendedKeyManager) { + km = new X509AliasKeyManager(alias, (X509ExtendedKeyManager) km); + } + + keyManagers[i] = km; + } + + return keyManagers; + } + + private static void validateAlias(KeyStore store, String alias) throws IllegalArgumentException, KeyStoreException { + if (!store.containsAlias(alias)) { + throw new IllegalArgumentException("The alias '" + alias + "' doesn't exist in the key store"); + } + + if (!store.isKeyEntry(alias)) { + throw new IllegalArgumentException("The alias '" + alias + "' in the keystore doesn't represent a key entry"); + } + } + + private static KeyStore loadStore(String storePath, final String password, String storeType) throws Exception { + KeyStore store = KeyStore.getInstance(storeType); + try (InputStream in = new FileInputStream(new File(storePath));) { + store.load(in, password != null ? password.toCharArray() : null); + } + + return store; + } + + private static TrustManager createTrustAllTrustManager() { + return new X509TrustManager() { + @Override + public void checkClientTrusted(X509Certificate[] x509Certificates, String s) throws CertificateException { + } + + @Override + public void checkServerTrusted(X509Certificate[] x509Certificates, String s) throws CertificateException { + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + }; + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/transport/NettyWSTransport.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/transport/NettyWSTransport.java new file mode 100644 index 0000000000..b28f523f01 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/transport/NettyWSTransport.java @@ -0,0 +1,472 @@ +/* + * 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 org.apache.activemq.transport.amqp.client.transport; + +import java.io.IOException; +import java.net.URI; +import java.security.Principal; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import io.netty.bootstrap.Bootstrap; +import io.netty.buffer.ByteBuf; +import io.netty.channel.Channel; +import io.netty.channel.ChannelFuture; +import io.netty.channel.ChannelFutureListener; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.ChannelOption; +import io.netty.channel.ChannelPromise; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.FixedRecvByteBufAllocator; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.socket.nio.NioSocketChannel; +import io.netty.handler.codec.http.DefaultHttpHeaders; +import io.netty.handler.codec.http.FullHttpResponse; +import io.netty.handler.codec.http.HttpClientCodec; +import io.netty.handler.codec.http.HttpObjectAggregator; +import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame; +import io.netty.handler.codec.http.websocketx.CloseWebSocketFrame; +import io.netty.handler.codec.http.websocketx.PongWebSocketFrame; +import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; +import io.netty.handler.codec.http.websocketx.WebSocketClientHandshaker; +import io.netty.handler.codec.http.websocketx.WebSocketClientHandshakerFactory; +import io.netty.handler.codec.http.websocketx.WebSocketFrame; +import io.netty.handler.codec.http.websocketx.WebSocketVersion; +import io.netty.handler.ssl.SslHandler; +import io.netty.util.CharsetUtil; +import io.netty.util.concurrent.Future; +import io.netty.util.concurrent.GenericFutureListener; +import org.apache.activemq.transport.amqp.client.util.IOExceptionSupport; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Transport for communicating over WebSockets + */ +public class NettyWSTransport implements NettyTransport { + + private static final Logger LOG = LoggerFactory.getLogger(NettyWSTransport.class); + + private static final int QUIET_PERIOD = 20; + private static final int SHUTDOWN_TIMEOUT = 100; + + protected Bootstrap bootstrap; + protected EventLoopGroup group; + protected Channel channel; + protected NettyTransportListener listener; + protected NettyTransportOptions options; + protected final URI remote; + protected boolean secure; + + private final AtomicBoolean connected = new AtomicBoolean(); + private final AtomicBoolean closed = new AtomicBoolean(); + private ChannelPromise handshakeFuture; + private IOException failureCause; + private Throwable pendingFailure; + + /** + * Create a new transport instance + * + * @param remoteLocation the URI that defines the remote resource to connect to. + * @param options the transport options used to configure the socket connection. + */ + public NettyWSTransport(URI remoteLocation, NettyTransportOptions options) { + this(null, remoteLocation, options); + } + + /** + * Create a new transport instance + * + * @param listener the TransportListener that will receive events from this Transport. + * @param remoteLocation the URI that defines the remote resource to connect to. + * @param options the transport options used to configure the socket connection. + */ + public NettyWSTransport(NettyTransportListener listener, URI remoteLocation, NettyTransportOptions options) { + this.options = options; + this.listener = listener; + this.remote = remoteLocation; + this.secure = remoteLocation.getScheme().equalsIgnoreCase("wss"); + } + + @Override + public void connect() throws IOException { + + if (listener == null) { + throw new IllegalStateException("A transport listener must be set before connection attempts."); + } + + group = new NioEventLoopGroup(1); + + bootstrap = new Bootstrap(); + bootstrap.group(group); + bootstrap.channel(NioSocketChannel.class); + bootstrap.handler(new ChannelInitializer() { + + @Override + public void initChannel(Channel connectedChannel) throws Exception { + configureChannel(connectedChannel); + } + }); + + configureNetty(bootstrap, getTransportOptions()); + + ChannelFuture future; + try { + future = bootstrap.connect(getRemoteHost(), getRemotePort()); + future.addListener(new ChannelFutureListener() { + + @Override + public void operationComplete(ChannelFuture future) throws Exception { + if (future.isSuccess()) { + handleConnected(future.channel()); + } + else if (future.isCancelled()) { + connectionFailed(future.channel(), new IOException("Connection attempt was cancelled")); + } + else { + connectionFailed(future.channel(), IOExceptionSupport.create(future.cause())); + } + } + }); + + future.sync(); + + // Now wait for WS protocol level handshake completion + handshakeFuture.await(); + } + catch (InterruptedException ex) { + LOG.debug("Transport connection attempt was interrupted."); + Thread.interrupted(); + failureCause = IOExceptionSupport.create(ex); + } + + if (failureCause != null) { + // Close out any Netty resources now as they are no longer needed. + if (channel != null) { + channel.close().syncUninterruptibly(); + channel = null; + } + if (group != null) { + group.shutdownGracefully(QUIET_PERIOD, SHUTDOWN_TIMEOUT, TimeUnit.MILLISECONDS); + group = null; + } + + throw failureCause; + } + else { + // Connected, allow any held async error to fire now and close the transport. + channel.eventLoop().execute(new Runnable() { + + @Override + public void run() { + if (pendingFailure != null) { + channel.pipeline().fireExceptionCaught(pendingFailure); + } + } + }); + } + } + + @Override + public boolean isConnected() { + return connected.get(); + } + + @Override + public boolean isSSL() { + return secure; + } + + @Override + public void close() throws IOException { + if (closed.compareAndSet(false, true)) { + connected.set(false); + if (channel != null) { + channel.close().syncUninterruptibly(); + } + if (group != null) { + group.shutdownGracefully(QUIET_PERIOD, SHUTDOWN_TIMEOUT, TimeUnit.MILLISECONDS); + } + } + } + + @Override + public ByteBuf allocateSendBuffer(int size) throws IOException { + checkConnected(); + return channel.alloc().ioBuffer(size, size); + } + + @Override + public void send(ByteBuf output) throws IOException { + checkConnected(); + int length = output.readableBytes(); + if (length == 0) { + return; + } + + LOG.trace("Attempted write of: {} bytes", length); + + channel.writeAndFlush(new BinaryWebSocketFrame(output)); + } + + @Override + public NettyTransportListener getTransportListener() { + return listener; + } + + @Override + public void setTransportListener(NettyTransportListener listener) { + this.listener = listener; + } + + @Override + public NettyTransportOptions getTransportOptions() { + if (options == null) { + if (isSSL()) { + options = NettyTransportSslOptions.INSTANCE; + } + else { + options = NettyTransportOptions.INSTANCE; + } + } + + return options; + } + + @Override + public URI getRemoteLocation() { + return remote; + } + + @Override + public Principal getLocalPrincipal() { + if (!isSSL()) { + throw new UnsupportedOperationException("Not connected to a secure channel"); + } + + SslHandler sslHandler = channel.pipeline().get(SslHandler.class); + + return sslHandler.engine().getSession().getLocalPrincipal(); + } + + //----- Internal implementation details, can be overridden as needed --// + + protected String getRemoteHost() { + return remote.getHost(); + } + + protected int getRemotePort() { + int port = remote.getPort(); + + if (port <= 0) { + if (isSSL()) { + port = getSslOptions().getDefaultSslPort(); + } + else { + port = getTransportOptions().getDefaultTcpPort(); + } + } + + return port; + } + + protected void configureNetty(Bootstrap bootstrap, NettyTransportOptions options) { + bootstrap.option(ChannelOption.TCP_NODELAY, options.isTcpNoDelay()); + bootstrap.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, options.getConnectTimeout()); + bootstrap.option(ChannelOption.SO_KEEPALIVE, options.isTcpKeepAlive()); + bootstrap.option(ChannelOption.SO_LINGER, options.getSoLinger()); + bootstrap.option(ChannelOption.ALLOCATOR, PartialPooledByteBufAllocator.INSTANCE); + + if (options.getSendBufferSize() != -1) { + bootstrap.option(ChannelOption.SO_SNDBUF, options.getSendBufferSize()); + } + + if (options.getReceiveBufferSize() != -1) { + bootstrap.option(ChannelOption.SO_RCVBUF, options.getReceiveBufferSize()); + bootstrap.option(ChannelOption.RCVBUF_ALLOCATOR, new FixedRecvByteBufAllocator(options.getReceiveBufferSize())); + } + + if (options.getTrafficClass() != -1) { + bootstrap.option(ChannelOption.IP_TOS, options.getTrafficClass()); + } + } + + protected void configureChannel(final Channel channel) throws Exception { + if (isSSL()) { + SslHandler sslHandler = NettyTransportSupport.createSslHandler(getRemoteLocation(), getSslOptions()); + sslHandler.handshakeFuture().addListener(new GenericFutureListener>() { + @Override + public void operationComplete(Future future) throws Exception { + if (future.isSuccess()) { + LOG.trace("SSL Handshake has completed: {}", channel); + connectionEstablished(channel); + } + else { + LOG.trace("SSL Handshake has failed: {}", channel); + connectionFailed(channel, IOExceptionSupport.create(future.cause())); + } + } + }); + + channel.pipeline().addLast(sslHandler); + } + + channel.pipeline().addLast(new HttpClientCodec()); + channel.pipeline().addLast(new HttpObjectAggregator(8192)); + channel.pipeline().addLast(new NettyTcpTransportHandler()); + } + + protected void handleConnected(final Channel channel) throws Exception { + if (!isSSL()) { + connectionEstablished(channel); + } + } + + //----- State change handlers and checks ---------------------------------// + + /** + * Called when the transport has successfully connected and is ready for use. + */ + protected void connectionEstablished(Channel connectedChannel) { + LOG.info("WebSocket connectionEstablished! {}", connectedChannel); + channel = connectedChannel; + connected.set(true); + } + + /** + * Called when the transport connection failed and an error should be returned. + * + * @param failedChannel The Channel instance that failed. + * @param cause An IOException that describes the cause of the failed connection. + */ + protected void connectionFailed(Channel failedChannel, IOException cause) { + failureCause = IOExceptionSupport.create(cause); + channel = failedChannel; + connected.set(false); + handshakeFuture.setFailure(cause); + } + + private NettyTransportSslOptions getSslOptions() { + return (NettyTransportSslOptions) getTransportOptions(); + } + + private void checkConnected() throws IOException { + if (!connected.get()) { + throw new IOException("Cannot send to a non-connected transport."); + } + } + + //----- Handle connection events -----------------------------------------// + + private class NettyTcpTransportHandler extends SimpleChannelInboundHandler { + + private final WebSocketClientHandshaker handshaker; + + NettyTcpTransportHandler() { + handshaker = WebSocketClientHandshakerFactory.newHandshaker(remote, WebSocketVersion.V13, "amqp", false, new DefaultHttpHeaders()); + } + + @Override + public void handlerAdded(ChannelHandlerContext context) { + LOG.trace("Handler has become added! Channel is {}", context.channel()); + handshakeFuture = context.newPromise(); + } + + @Override + public void channelActive(ChannelHandlerContext context) throws Exception { + LOG.trace("Channel has become active! Channel is {}", context.channel()); + handshaker.handshake(context.channel()); + } + + @Override + public void channelInactive(ChannelHandlerContext context) throws Exception { + LOG.trace("Channel has gone inactive! Channel is {}", context.channel()); + if (connected.compareAndSet(true, false) && !closed.get()) { + LOG.trace("Firing onTransportClosed listener"); + listener.onTransportClosed(); + } + } + + @Override + public void exceptionCaught(ChannelHandlerContext context, Throwable cause) throws Exception { + LOG.trace("Exception on channel! Channel is {} -> {}", context.channel(), cause.getMessage()); + LOG.trace("Error Stack: ", cause); + if (connected.compareAndSet(true, false) && !closed.get()) { + LOG.trace("Firing onTransportError listener"); + if (pendingFailure != null) { + listener.onTransportError(pendingFailure); + } + else { + listener.onTransportError(cause); + } + } + else { + // Hold the first failure for later dispatch if connect succeeds. + // This will then trigger disconnect using the first error reported. + if (pendingFailure != null) { + LOG.trace("Holding error until connect succeeds: {}", cause.getMessage()); + pendingFailure = cause; + } + + if (!handshakeFuture.isDone()) { + handshakeFuture.setFailure(cause); + } + } + } + + @Override + protected void channelRead0(ChannelHandlerContext ctx, Object message) throws Exception { + LOG.trace("New data read: incoming: {}", message); + + Channel ch = ctx.channel(); + if (!handshaker.isHandshakeComplete()) { + handshaker.finishHandshake(ch, (FullHttpResponse) message); + LOG.info("WebSocket Client connected! {}", ctx.channel()); + handshakeFuture.setSuccess(); + return; + } + + // We shouldn't get this since we handle the handshake previously. + if (message instanceof FullHttpResponse) { + FullHttpResponse response = (FullHttpResponse) message; + throw new IllegalStateException("Unexpected FullHttpResponse (getStatus=" + response.getStatus() + + ", content=" + response.content().toString(CharsetUtil.UTF_8) + ')'); + } + + WebSocketFrame frame = (WebSocketFrame) message; + if (frame instanceof TextWebSocketFrame) { + TextWebSocketFrame textFrame = (TextWebSocketFrame) frame; + LOG.warn("WebSocket Client received message: " + textFrame.text()); + ctx.fireExceptionCaught(new IOException("Received invalid frame over WebSocket.")); + } + else if (frame instanceof BinaryWebSocketFrame) { + BinaryWebSocketFrame binaryFrame = (BinaryWebSocketFrame) frame; + LOG.info("WebSocket Client received data: {} bytes", binaryFrame.content().readableBytes()); + listener.onData(binaryFrame.content()); + } + else if (frame instanceof PongWebSocketFrame) { + LOG.trace("WebSocket Client received pong"); + } + else if (frame instanceof CloseWebSocketFrame) { + LOG.trace("WebSocket Client received closing"); + ch.close(); + } + } + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/transport/PartialPooledByteBufAllocator.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/transport/PartialPooledByteBufAllocator.java new file mode 100644 index 0000000000..c3c428660b --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/transport/PartialPooledByteBufAllocator.java @@ -0,0 +1,134 @@ +/* + * 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 org.apache.activemq.transport.amqp.client.transport; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufAllocator; +import io.netty.buffer.CompositeByteBuf; +import io.netty.buffer.PooledByteBufAllocator; +import io.netty.buffer.UnpooledByteBufAllocator; + +/** + * A {@link ByteBufAllocator} which is partial pooled. Which means only direct + * {@link ByteBuf}s are pooled. The rest is unpooled. + * + */ +public class PartialPooledByteBufAllocator implements ByteBufAllocator { + + private static final ByteBufAllocator POOLED = new PooledByteBufAllocator(false); + private static final ByteBufAllocator UNPOOLED = new UnpooledByteBufAllocator(false); + + public static final PartialPooledByteBufAllocator INSTANCE = new PartialPooledByteBufAllocator(); + + private PartialPooledByteBufAllocator() { + } + + @Override + public ByteBuf buffer() { + return UNPOOLED.heapBuffer(); + } + + @Override + public ByteBuf buffer(int initialCapacity) { + return UNPOOLED.heapBuffer(initialCapacity); + } + + @Override + public ByteBuf buffer(int initialCapacity, int maxCapacity) { + return UNPOOLED.heapBuffer(initialCapacity, maxCapacity); + } + + @Override + public ByteBuf ioBuffer() { + return UNPOOLED.heapBuffer(); + } + + @Override + public ByteBuf ioBuffer(int initialCapacity) { + return UNPOOLED.heapBuffer(initialCapacity); + } + + @Override + public ByteBuf ioBuffer(int initialCapacity, int maxCapacity) { + return UNPOOLED.heapBuffer(initialCapacity, maxCapacity); + } + + @Override + public ByteBuf heapBuffer() { + return UNPOOLED.heapBuffer(); + } + + @Override + public ByteBuf heapBuffer(int initialCapacity) { + return UNPOOLED.heapBuffer(initialCapacity); + } + + @Override + public ByteBuf heapBuffer(int initialCapacity, int maxCapacity) { + return UNPOOLED.heapBuffer(initialCapacity, maxCapacity); + } + + @Override + public ByteBuf directBuffer() { + return POOLED.directBuffer(); + } + + @Override + public ByteBuf directBuffer(int initialCapacity) { + return POOLED.directBuffer(initialCapacity); + } + + @Override + public ByteBuf directBuffer(int initialCapacity, int maxCapacity) { + return POOLED.directBuffer(initialCapacity, maxCapacity); + } + + @Override + public CompositeByteBuf compositeBuffer() { + return UNPOOLED.compositeHeapBuffer(); + } + + @Override + public CompositeByteBuf compositeBuffer(int maxNumComponents) { + return UNPOOLED.compositeHeapBuffer(maxNumComponents); + } + + @Override + public CompositeByteBuf compositeHeapBuffer() { + return UNPOOLED.compositeHeapBuffer(); + } + + @Override + public CompositeByteBuf compositeHeapBuffer(int maxNumComponents) { + return UNPOOLED.compositeHeapBuffer(maxNumComponents); + } + + @Override + public CompositeByteBuf compositeDirectBuffer() { + return POOLED.compositeDirectBuffer(); + } + + @Override + public CompositeByteBuf compositeDirectBuffer(int maxNumComponents) { + return POOLED.compositeDirectBuffer(); + } + + @Override + public boolean isDirectBufferPooled() { + return true; + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/transport/X509AliasKeyManager.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/transport/X509AliasKeyManager.java new file mode 100644 index 0000000000..42d6a0b721 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/transport/X509AliasKeyManager.java @@ -0,0 +1,86 @@ +/* + * 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 org.apache.activemq.transport.amqp.client.transport; + +import javax.net.ssl.SSLEngine; +import javax.net.ssl.X509ExtendedKeyManager; +import java.net.Socket; +import java.security.Principal; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; + +/** + * An X509ExtendedKeyManager wrapper which always chooses and only + * returns the given alias, and defers retrieval to the delegate + * key manager. + */ +public class X509AliasKeyManager extends X509ExtendedKeyManager { + + private X509ExtendedKeyManager delegate; + private String alias; + + public X509AliasKeyManager(String alias, X509ExtendedKeyManager delegate) throws IllegalArgumentException { + if (alias == null) { + throw new IllegalArgumentException("The given key alias must not be null."); + } + + this.alias = alias; + this.delegate = delegate; + } + + @Override + public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) { + return alias; + } + + @Override + public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) { + return alias; + } + + @Override + public X509Certificate[] getCertificateChain(String alias) { + return delegate.getCertificateChain(alias); + } + + @Override + public String[] getClientAliases(String keyType, Principal[] issuers) { + return new String[]{alias}; + } + + @Override + public PrivateKey getPrivateKey(String alias) { + return delegate.getPrivateKey(alias); + } + + @Override + public String[] getServerAliases(String keyType, Principal[] issuers) { + return new String[]{alias}; + } + + @Override + public String chooseEngineClientAlias(String[] keyType, Principal[] issuers, SSLEngine engine) { + return alias; + } + + @Override + public String chooseEngineServerAlias(String keyType, Principal[] issuers, SSLEngine engine) { + return alias; + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/AsyncResult.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/AsyncResult.java new file mode 100644 index 0000000000..bb717468c4 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/AsyncResult.java @@ -0,0 +1,46 @@ +/** + * 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 org.apache.activemq.transport.amqp.client.util; + +/** + * Defines a result interface for Asynchronous operations. + */ +public interface AsyncResult { + + /** + * If the operation fails this method is invoked with the Exception + * that caused the failure. + * + * @param result The error that resulted in this asynchronous operation failing. + */ + void onFailure(Throwable result); + + /** + * If the operation succeeds the resulting value produced is set to null and + * the waiting parties are signaled. + */ + void onSuccess(); + + /** + * Returns true if the AsyncResult has completed. The task is considered complete + * regardless if it succeeded or failed. + * + * @return returns true if the asynchronous operation has completed. + */ + boolean isComplete(); + +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/ClientFuture.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/ClientFuture.java new file mode 100644 index 0000000000..12d38fd235 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/ClientFuture.java @@ -0,0 +1,110 @@ +/** + * 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 org.apache.activemq.transport.amqp.client.util; + +import java.io.IOException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Asynchronous Client Future class. + */ +public class ClientFuture implements AsyncResult { + + private final AtomicBoolean completer = new AtomicBoolean(); + private final CountDownLatch latch = new CountDownLatch(1); + private final ClientFutureSynchronization synchronization; + private volatile Throwable error; + + public ClientFuture() { + this(null); + } + + public ClientFuture(ClientFutureSynchronization synchronization) { + this.synchronization = synchronization; + } + + @Override + public boolean isComplete() { + return latch.getCount() == 0; + } + + @Override + public void onFailure(Throwable result) { + if (completer.compareAndSet(false, true)) { + error = result; + if (synchronization != null) { + synchronization.onPendingFailure(error); + } + latch.countDown(); + } + } + + @Override + public void onSuccess() { + if (completer.compareAndSet(false, true)) { + if (synchronization != null) { + synchronization.onPendingSuccess(); + } + latch.countDown(); + } + } + + /** + * Timed wait for a response to a pending operation. + * + * @param amount The amount of time to wait before abandoning the wait. + * @param unit The unit to use for this wait period. + * @throws IOException if an error occurs while waiting for the response. + */ + public void sync(long amount, TimeUnit unit) throws IOException { + try { + latch.await(amount, unit); + } + catch (InterruptedException e) { + Thread.interrupted(); + throw IOExceptionSupport.create(e); + } + + failOnError(); + } + + /** + * Waits for a response to some pending operation. + * + * @throws IOException if an error occurs while waiting for the response. + */ + public void sync() throws IOException { + try { + latch.await(); + } + catch (InterruptedException e) { + Thread.interrupted(); + throw IOExceptionSupport.create(e); + } + + failOnError(); + } + + private void failOnError() throws IOException { + Throwable cause = error; + if (cause != null) { + throw IOExceptionSupport.create(cause); + } + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/ClientFutureSynchronization.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/ClientFutureSynchronization.java new file mode 100644 index 0000000000..e279bc14a1 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/ClientFutureSynchronization.java @@ -0,0 +1,30 @@ +/** + * 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 org.apache.activemq.transport.amqp.client.util; + +/** + * Synchronization callback interface used to execute state updates + * or similar tasks in the thread context where the associated + * ProviderFuture is managed. + */ +public interface ClientFutureSynchronization { + + void onPendingSuccess(); + + void onPendingFailure(Throwable cause); + +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/IOExceptionSupport.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/IOExceptionSupport.java new file mode 100644 index 0000000000..70d88e6d4f --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/IOExceptionSupport.java @@ -0,0 +1,45 @@ +/* + * 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 org.apache.activemq.transport.amqp.client.util; + +import java.io.IOException; + +/** + * Used to make throwing IOException instances easier. + */ +public class IOExceptionSupport { + + /** + * Checks the given cause to determine if it's already an IOException type and + * if not creates a new IOException to wrap it. + * + * @param cause The initiating exception that should be cast or wrapped. + * @return an IOException instance. + */ + public static IOException create(Throwable cause) { + if (cause instanceof IOException) { + return (IOException) cause; + } + + String message = cause.getMessage(); + if (message == null || message.length() == 0) { + message = cause.toString(); + } + + return new IOException(message, cause); + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/IdGenerator.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/IdGenerator.java new file mode 100644 index 0000000000..c662b59e76 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/IdGenerator.java @@ -0,0 +1,274 @@ +/* + * 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 org.apache.activemq.transport.amqp.client.util; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.UnknownHostException; +import java.util.concurrent.atomic.AtomicLong; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Generator for Globally unique Strings. + */ +public class IdGenerator { + + private static final Logger LOG = LoggerFactory.getLogger(IdGenerator.class); + private static final String UNIQUE_STUB; + private static int instanceCount; + private static String hostName; + private String seed; + private final AtomicLong sequence = new AtomicLong(1); + private int length; + public static final String PROPERTY_IDGENERATOR_PORT = "activemq.idgenerator.port"; + + static { + String stub = ""; + boolean canAccessSystemProps = true; + try { + SecurityManager sm = System.getSecurityManager(); + if (sm != null) { + sm.checkPropertiesAccess(); + } + } + catch (SecurityException se) { + canAccessSystemProps = false; + } + + if (canAccessSystemProps) { + int idGeneratorPort = 0; + ServerSocket ss = null; + try { + idGeneratorPort = Integer.parseInt(System.getProperty(PROPERTY_IDGENERATOR_PORT, "0")); + LOG.trace("Using port {}", idGeneratorPort); + hostName = getLocalHostName(); + ss = new ServerSocket(idGeneratorPort); + stub = "-" + ss.getLocalPort() + "-" + System.currentTimeMillis() + "-"; + Thread.sleep(100); + } + catch (Exception e) { + if (LOG.isTraceEnabled()) { + LOG.trace("could not generate unique stub by using DNS and binding to local port", e); + } + else { + LOG.warn("could not generate unique stub by using DNS and binding to local port: {} {}", e.getClass().getCanonicalName(), e.getMessage()); + } + + // Restore interrupted state so higher level code can deal with it. + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + } + finally { + if (ss != null) { + try { + ss.close(); + } + catch (IOException ioe) { + if (LOG.isTraceEnabled()) { + LOG.trace("Closing the server socket failed", ioe); + } + else { + LOG.warn("Closing the server socket failed" + " due " + ioe.getMessage()); + } + } + } + } + } + + if (hostName == null) { + hostName = "localhost"; + } + hostName = sanitizeHostName(hostName); + + if (stub.length() == 0) { + stub = "-1-" + System.currentTimeMillis() + "-"; + } + UNIQUE_STUB = stub; + } + + /** + * Construct an IdGenerator + * + * @param prefix The prefix value that is applied to all generated IDs. + */ + public IdGenerator(String prefix) { + synchronized (UNIQUE_STUB) { + this.seed = prefix + UNIQUE_STUB + (instanceCount++) + ":"; + this.length = this.seed.length() + ("" + Long.MAX_VALUE).length(); + } + } + + public IdGenerator() { + this("ID:" + hostName); + } + + /** + * As we have to find the host name as a side-affect of generating a unique stub, we allow + * it's easy retrieval here + * + * @return the local host name + */ + public static String getHostName() { + return hostName; + } + + /** + * Generate a unique id + * + * @return a unique id + */ + public synchronized String generateId() { + StringBuilder sb = new StringBuilder(length); + sb.append(seed); + sb.append(sequence.getAndIncrement()); + return sb.toString(); + } + + public static String sanitizeHostName(String hostName) { + boolean changed = false; + + StringBuilder sb = new StringBuilder(); + for (char ch : hostName.toCharArray()) { + // only include ASCII chars + if (ch < 127) { + sb.append(ch); + } + else { + changed = true; + } + } + + if (changed) { + String newHost = sb.toString(); + LOG.info("Sanitized hostname from: {} to: {}", hostName, newHost); + return newHost; + } + else { + return hostName; + } + } + + /** + * Generate a unique ID - that is friendly for a URL or file system + * + * @return a unique id + */ + public String generateSanitizedId() { + String result = generateId(); + result = result.replace(':', '-'); + result = result.replace('_', '-'); + result = result.replace('.', '-'); + return result; + } + + /** + * From a generated id - return the seed (i.e. minus the count) + * + * @param id the generated identifier + * @return the seed + */ + public static String getSeedFromId(String id) { + String result = id; + if (id != null) { + int index = id.lastIndexOf(':'); + if (index > 0 && (index + 1) < id.length()) { + result = id.substring(0, index); + } + } + return result; + } + + /** + * From a generated id - return the generator count + * + * @param id The ID that will be parsed for a sequence number. + * @return the sequence value parsed from the given ID. + */ + public static long getSequenceFromId(String id) { + long result = -1; + if (id != null) { + int index = id.lastIndexOf(':'); + + if (index > 0 && (index + 1) < id.length()) { + String numStr = id.substring(index + 1, id.length()); + result = Long.parseLong(numStr); + } + } + return result; + } + + /** + * Does a proper compare on the Id's + * + * @param id1 the lhs of the comparison. + * @param id2 the rhs of the comparison. + * @return 0 if equal else a positive if {@literal id1 > id2} ... + */ + public static int compare(String id1, String id2) { + int result = -1; + String seed1 = IdGenerator.getSeedFromId(id1); + String seed2 = IdGenerator.getSeedFromId(id2); + if (seed1 != null && seed2 != null) { + result = seed1.compareTo(seed2); + if (result == 0) { + long count1 = IdGenerator.getSequenceFromId(id1); + long count2 = IdGenerator.getSequenceFromId(id2); + result = (int) (count1 - count2); + } + } + return result; + } + + /** + * When using the {@link java.net.InetAddress#getHostName()} method in an + * environment where neither a proper DNS lookup nor an /etc/hosts + * entry exists for a given host, the following exception will be thrown: + * + * java.net.UnknownHostException: <hostname>: <hostname> + * at java.net.InetAddress.getLocalHost(InetAddress.java:1425) + * ... + * + * Instead of just throwing an UnknownHostException and giving up, this + * method grabs a suitable hostname from the exception and prevents the + * exception from being thrown. If a suitable hostname cannot be acquired + * from the exception, only then is the UnknownHostException thrown. + * + * @return The hostname + * @throws UnknownHostException if the given host cannot be looked up. + * @see java.net.InetAddress#getLocalHost() + * @see java.net.InetAddress#getHostName() + */ + protected static String getLocalHostName() throws UnknownHostException { + try { + return (InetAddress.getLocalHost()).getHostName(); + } + catch (UnknownHostException uhe) { + String host = uhe.getMessage(); // host = "hostname: hostname" + if (host != null) { + int colon = host.indexOf(':'); + if (colon > 0) { + return host.substring(0, colon); + } + } + throw uhe; + } + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/NoOpAsyncResult.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/NoOpAsyncResult.java new file mode 100644 index 0000000000..5dd4d12dc6 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/NoOpAsyncResult.java @@ -0,0 +1,40 @@ +/* + * 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 org.apache.activemq.transport.amqp.client.util; + +/** + * Simple NoOp implementation used when the result of the operation does not matter. + */ +public class NoOpAsyncResult implements AsyncResult { + + public static final NoOpAsyncResult INSTANCE = new NoOpAsyncResult(); + + @Override + public void onFailure(Throwable result) { + + } + + @Override + public void onSuccess() { + + } + + @Override + public boolean isComplete() { + return true; + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/PropertyUtil.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/PropertyUtil.java new file mode 100644 index 0000000000..1285a0fc55 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/PropertyUtil.java @@ -0,0 +1,533 @@ +/* + * 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 org.apache.activemq.transport.amqp.client.util; + +import javax.net.ssl.SSLContext; +import java.beans.BeanInfo; +import java.beans.Introspector; +import java.beans.PropertyDescriptor; +import java.io.UnsupportedEncodingException; +import java.lang.reflect.Method; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Properties; + +/** + * Utilities for properties + */ +public class PropertyUtil { + + /** + * Creates a URI from the original URI and the given parameters. + * + * @param originalURI The URI whose current parameters are removed and replaced with the given remainder value. + * @param params The URI params that should be used to replace the current ones in the target. + * @return a new URI that matches the original one but has its query options replaced with + * the given ones. + * @throws URISyntaxException if the given URI is invalid. + */ + public static URI replaceQuery(URI originalURI, Map params) throws URISyntaxException { + String s = createQueryString(params); + if (s.length() == 0) { + s = null; + } + return replaceQuery(originalURI, s); + } + + /** + * Creates a URI with the given query, removing an previous query value from the given URI. + * + * @param uri The source URI whose existing query is replaced with the newly supplied one. + * @param query The new URI query string that should be appended to the given URI. + * @return a new URI that is a combination of the original URI and the given query string. + * @throws URISyntaxException if the given URI is invalid. + */ + public static URI replaceQuery(URI uri, String query) throws URISyntaxException { + String schemeSpecificPart = uri.getRawSchemeSpecificPart(); + // strip existing query if any + int questionMark = schemeSpecificPart.lastIndexOf("?"); + // make sure question mark is not within parentheses + if (questionMark < schemeSpecificPart.lastIndexOf(")")) { + questionMark = -1; + } + if (questionMark > 0) { + schemeSpecificPart = schemeSpecificPart.substring(0, questionMark); + } + if (query != null && query.length() > 0) { + schemeSpecificPart += "?" + query; + } + return new URI(uri.getScheme(), schemeSpecificPart, uri.getFragment()); + } + + /** + * Creates a URI with the given query, removing an previous query value from the given URI. + * + * @param uri The source URI whose existing query is replaced with the newly supplied one. + * @return a new URI that is a combination of the original URI and the given query string. + * @throws URISyntaxException if the given URI is invalid. + */ + public static URI eraseQuery(URI uri) throws URISyntaxException { + return replaceQuery(uri, (String) null); + } + + /** + * Given a key / value mapping, create and return a URI formatted query string that is valid + * and can be appended to a URI. + * + * @param options The Mapping that will create the new Query string. + * @return a URI formatted query string. + * @throws URISyntaxException if the given URI is invalid. + */ + public static String createQueryString(Map options) throws URISyntaxException { + try { + if (options.size() > 0) { + StringBuffer rc = new StringBuffer(); + boolean first = true; + for (Entry entry : options.entrySet()) { + if (first) { + first = false; + } + else { + rc.append("&"); + } + rc.append(URLEncoder.encode(entry.getKey(), "UTF-8")); + rc.append("="); + rc.append(URLEncoder.encode((String) entry.getValue(), "UTF-8")); + } + return rc.toString(); + } + else { + return ""; + } + } + catch (UnsupportedEncodingException e) { + throw (URISyntaxException) new URISyntaxException(e.toString(), "Invalid encoding").initCause(e); + } + } + + /** + * Get properties from a URI and return them in a new {@code Map} instance. + * + * If the URI is null or the query string of the URI is null an empty Map is returned. + * + * @param uri the URI whose parameters are to be parsed. + * @return Map of properties + * @throws Exception if an error occurs while parsing the query options. + */ + public static Map parseParameters(URI uri) throws Exception { + if (uri == null || uri.getQuery() == null) { + return Collections.emptyMap(); + } + + return parseQuery(stripPrefix(uri.getQuery(), "?")); + } + + /** + * Parse properties from a named resource -eg. a URI or a simple name e.g. + * {@literal foo?name="fred"&size=2} + * + * @param uri the URI whose parameters are to be parsed. + * @return Map of properties + * @throws Exception if an error occurs while parsing the query options. + */ + public static Map parseParameters(String uri) throws Exception { + if (uri == null) { + return Collections.emptyMap(); + } + + return parseQuery(stripUpto(uri, '?')); + } + + /** + * Get properties from a URI query string. + * + * @param queryString the string value returned from a call to the URI class getQuery method. + * @return Map of properties from the parsed string. + * @throws Exception if an error occurs while parsing the query options. + */ + public static Map parseQuery(String queryString) throws Exception { + if (queryString != null && !queryString.isEmpty()) { + Map rc = new HashMap<>(); + String[] parameters = queryString.split("&"); + for (int i = 0; i < parameters.length; i++) { + int p = parameters[i].indexOf("="); + if (p >= 0) { + String name = URLDecoder.decode(parameters[i].substring(0, p), "UTF-8"); + String value = URLDecoder.decode(parameters[i].substring(p + 1), "UTF-8"); + rc.put(name, value); + } + else { + rc.put(parameters[i], null); + } + } + return rc; + } + + return Collections.emptyMap(); + } + + /** + * Given a map of properties, filter out only those prefixed with the given value, the + * values filtered are returned in a new Map instance. + * + * @param properties The map of properties to filter. + * @param optionPrefix The prefix value to use when filtering. + * @return a filter map with only values that match the given prefix. + */ + public static Map filterProperties(Map properties, String optionPrefix) { + if (properties == null) { + throw new IllegalArgumentException("The given properties object was null."); + } + + HashMap rc = new HashMap<>(properties.size()); + + for (Iterator> iter = properties.entrySet().iterator(); iter.hasNext(); ) { + Entry entry = iter.next(); + if (entry.getKey().startsWith(optionPrefix)) { + String name = entry.getKey().substring(optionPrefix.length()); + rc.put(name, entry.getValue()); + iter.remove(); + } + } + + return rc; + } + + /** + * Enumerate the properties of the target object and add them as additional entries + * to the query string of the given string URI. + * + * @param uri The string URI value to append the object properties to. + * @param bean The Object whose properties will be added to the target URI. + * @return a new String value that is the original URI with the added bean properties. + * @throws Exception if an error occurs while enumerating the bean properties. + */ + public static String addPropertiesToURIFromBean(String uri, Object bean) throws Exception { + Map properties = PropertyUtil.getProperties(bean); + return PropertyUtil.addPropertiesToURI(uri, properties); + } + + /** + * Enumerate the properties of the target object and add them as additional entries + * to the query string of the given URI. + * + * @param uri The URI value to append the object properties to. + * @param properties The Object whose properties will be added to the target URI. + * @return a new String value that is the original URI with the added bean properties. + * @throws Exception if an error occurs while enumerating the bean properties. + */ + public static String addPropertiesToURI(URI uri, Map properties) throws Exception { + return addPropertiesToURI(uri.toString(), properties); + } + + /** + * Append the given properties to the query portion of the given URI. + * + * @param uri The string URI value to append the object properties to. + * @param properties The properties that will be added to the target URI. + * @return a new String value that is the original URI with the added properties. + * @throws Exception if an error occurs while building the new URI string. + */ + public static String addPropertiesToURI(String uri, Map properties) throws Exception { + String result = uri; + if (uri != null && properties != null) { + StringBuilder base = new StringBuilder(stripBefore(uri, '?')); + Map map = parseParameters(uri); + if (!map.isEmpty()) { + map.putAll(properties); + } + else { + map = properties; + } + if (!map.isEmpty()) { + base.append('?'); + boolean first = true; + for (Map.Entry entry : map.entrySet()) { + if (!first) { + base.append('&'); + } + first = false; + base.append(entry.getKey()).append("=").append(entry.getValue()); + } + result = base.toString(); + } + } + return result; + } + + /** + * Set properties on an object using the provided map. The return value + * indicates if all properties from the given map were set on the target object. + * + * @param target the object whose properties are to be set from the map options. + * @param properties the properties that should be applied to the given object. + * @return true if all values in the properties map were applied to the target object. + */ + public static Map setProperties(Object target, Map properties) { + if (target == null) { + throw new IllegalArgumentException("target object cannot be null"); + } + if (properties == null) { + throw new IllegalArgumentException("Given Properties object cannot be null"); + } + + Map unmatched = new HashMap<>(); + + for (Map.Entry entry : properties.entrySet()) { + if (!setProperty(target, entry.getKey(), entry.getValue())) { + unmatched.put(entry.getKey(), entry.getValue()); + } + } + + return Collections.unmodifiableMap(unmatched); + } + + //TODO: common impl for above and below methods. + + /** + * Set properties on an object using the provided Properties object. The return value + * indicates if all properties from the given map were set on the target object. + * + * @param target the object whose properties are to be set from the map options. + * @param properties the properties that should be applied to the given object. + * @return an unmodifiable map with any values that could not be applied to the target. + */ + public static Map setProperties(Object target, Properties properties) { + if (target == null) { + throw new IllegalArgumentException("target object cannot be null"); + } + if (properties == null) { + throw new IllegalArgumentException("Given Properties object cannot be null"); + } + + Map unmatched = new HashMap<>(); + + for (Map.Entry entry : properties.entrySet()) { + if (!setProperty(target, (String) entry.getKey(), entry.getValue())) { + unmatched.put((String) entry.getKey(), entry.getValue()); + } + } + + return Collections.unmodifiableMap(unmatched); + } + + /** + * Get properties from an object using reflection. If the passed object is null an + * empty Map is returned. + * + * @param object the Object whose properties are to be extracted. + * @return Map of properties extracted from the given object. + * @throws Exception if an error occurs while examining the object's properties. + */ + public static Map getProperties(Object object) throws Exception { + if (object == null) { + return Collections.emptyMap(); + } + + Map properties = new LinkedHashMap<>(); + BeanInfo beanInfo = Introspector.getBeanInfo(object.getClass()); + Object[] NULL_ARG = {}; + PropertyDescriptor[] propertyDescriptors = beanInfo.getPropertyDescriptors(); + if (propertyDescriptors != null) { + for (int i = 0; i < propertyDescriptors.length; i++) { + PropertyDescriptor pd = propertyDescriptors[i]; + if (pd.getReadMethod() != null && !pd.getName().equals("class") && !pd.getName().equals("properties") && !pd.getName().equals("reference")) { + Object value = pd.getReadMethod().invoke(object, NULL_ARG); + if (value != null) { + if (value instanceof Boolean || value instanceof Number || value instanceof String || value instanceof URI || value instanceof URL) { + properties.put(pd.getName(), ("" + value)); + } + else if (value instanceof SSLContext) { + // ignore this one.. + } + else { + Map inner = getProperties(value); + for (Map.Entry entry : inner.entrySet()) { + properties.put(pd.getName() + "." + entry.getKey(), entry.getValue()); + } + } + } + } + } + } + + return properties; + } + + /** + * Find a specific property getter in a given object based on a property name. + * + * @param object the object to search. + * @param name the property name to search for. + * @return the result of invoking the specific property get method. + * @throws Exception if an error occurs while searching the object's bean info. + */ + public static Object getProperty(Object object, String name) throws Exception { + BeanInfo beanInfo = Introspector.getBeanInfo(object.getClass()); + PropertyDescriptor[] propertyDescriptors = beanInfo.getPropertyDescriptors(); + if (propertyDescriptors != null) { + for (int i = 0; i < propertyDescriptors.length; i++) { + PropertyDescriptor pd = propertyDescriptors[i]; + if (pd.getReadMethod() != null && pd.getName().equals(name)) { + return pd.getReadMethod().invoke(object); + } + } + } + return null; + } + + /** + * Set a property named property on a given Object. + *

+ * The object is searched for an set method that would match the given named + * property and if one is found. If necessary an attempt will be made to convert + * the new value to an acceptable type. + * + * @param target The object whose property is to be set. + * @param name The name of the property to set. + * @param value The new value to set for the named property. + * @return true if the property was able to be set on the target object. + */ + public static boolean setProperty(Object target, String name, Object value) { + try { + int dotPos = name.indexOf("."); + while (dotPos >= 0) { + String getterName = name.substring(0, dotPos); + target = getProperty(target, getterName); + name = name.substring(dotPos + 1); + dotPos = name.indexOf("."); + } + + Class clazz = target.getClass(); + Method setter = findSetterMethod(clazz, name); + if (setter == null) { + return false; + } + // If the type is null or it matches the needed type, just use the + // value directly + if (value == null || value.getClass() == setter.getParameterTypes()[0]) { + setter.invoke(target, new Object[]{value}); + } + else { + setter.invoke(target, new Object[]{convert(value, setter.getParameterTypes()[0])}); + } + return true; + } + catch (Throwable ignore) { + return false; + } + } + + /** + * Return a String minus the given prefix. If the string does not start + * with the given prefix the original string value is returned. + * + * @param value The String whose prefix is to be removed. + * @param prefix The prefix string to remove from the target string. + * @return stripped version of the original input string. + */ + public static String stripPrefix(String value, String prefix) { + if (value != null && prefix != null && value.startsWith(prefix)) { + return value.substring(prefix.length()); + } + return value; + } + + /** + * Return a portion of a String value by looking beyond the given + * character. + * + * @param value The string value to split + * @param c The character that marks the split point. + * @return the sub-string value starting beyond the given character. + */ + public static String stripUpto(String value, char c) { + String result = null; + if (value != null) { + int index = value.indexOf(c); + if (index > 0) { + result = value.substring(index + 1); + } + } + return result; + } + + /** + * Return a String up to and including character + * + * @param value The string value to split + * @param c The character that marks the start of split point. + * @return the sub-string value starting from the given character. + */ + public static String stripBefore(String value, char c) { + String result = value; + if (value != null) { + int index = value.indexOf(c); + if (index > 0) { + result = value.substring(0, index); + } + } + return result; + } + + private static Method findSetterMethod(Class clazz, String name) { + // Build the method name. + name = "set" + name.substring(0, 1).toUpperCase() + name.substring(1); + Method[] methods = clazz.getMethods(); + for (int i = 0; i < methods.length; i++) { + Method method = methods[i]; + Class[] params = method.getParameterTypes(); + if (method.getName().equals(name) && params.length == 1) { + return method; + } + } + return null; + } + + private static Object convert(Object value, Class type) throws Exception { + if (value == null) { + if (boolean.class.isAssignableFrom(type)) { + return Boolean.FALSE; + } + return null; + } + + if (type.isAssignableFrom(value.getClass())) { + return type.cast(value); + } + + // special for String[] as we do not want to use a PropertyEditor for that + if (type.isAssignableFrom(String[].class)) { + return StringArrayConverter.convertToStringArray(value); + } + + if (type == URI.class) { + return new URI(value.toString()); + } + + return TypeConversionSupport.convert(value, type); + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/StringArrayConverter.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/StringArrayConverter.java new file mode 100644 index 0000000000..3fc9eb40f5 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/StringArrayConverter.java @@ -0,0 +1,64 @@ +/* + * 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 org.apache.activemq.transport.amqp.client.util; + +import java.util.ArrayList; +import java.util.List; +import java.util.StringTokenizer; + +/** + * Class for converting to/from String[] to be used instead of a + * {@link java.beans.PropertyEditor} which otherwise causes memory leaks as the + * JDK {@link java.beans.PropertyEditorManager} is a static class and has strong + * references to classes, causing problems in hot-deployment environments. + */ +public class StringArrayConverter { + + public static String[] convertToStringArray(Object value) { + if (value == null) { + return null; + } + + String text = value.toString(); + if (text == null || text.isEmpty()) { + return null; + } + + StringTokenizer stok = new StringTokenizer(text, ","); + final List list = new ArrayList<>(); + + while (stok.hasMoreTokens()) { + list.add(stok.nextToken()); + } + + String[] array = list.toArray(new String[list.size()]); + return array; + } + + public static String convertToString(String[] value) { + if (value == null || value.length == 0) { + return null; + } + + StringBuffer result = new StringBuffer(String.valueOf(value[0])); + for (int i = 1; i < value.length; i++) { + result.append(",").append(value[i]); + } + + return result.toString(); + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/TypeConversionSupport.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/TypeConversionSupport.java new file mode 100644 index 0000000000..7d075510e8 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/TypeConversionSupport.java @@ -0,0 +1,218 @@ +/** + * 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 org.apache.activemq.transport.amqp.client.util; + +import java.util.Date; +import java.util.HashMap; + +public final class TypeConversionSupport { + + static class ConversionKey { + + final Class from; + final Class to; + final int hashCode; + + ConversionKey(Class from, Class to) { + this.from = from; + this.to = to; + this.hashCode = from.hashCode() ^ (to.hashCode() << 1); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (o == null || o.getClass() != this.getClass()) { + return false; + } + + ConversionKey x = (ConversionKey) o; + return x.from == from && x.to == to; + } + + @Override + public int hashCode() { + return hashCode; + } + } + + interface Converter { + + Object convert(Object value); + } + + private static final HashMap CONVERSION_MAP = new HashMap<>(); + + static { + Converter toStringConverter = new Converter() { + @Override + public Object convert(Object value) { + return value.toString(); + } + }; + CONVERSION_MAP.put(new ConversionKey(Boolean.class, String.class), toStringConverter); + CONVERSION_MAP.put(new ConversionKey(Byte.class, String.class), toStringConverter); + CONVERSION_MAP.put(new ConversionKey(Short.class, String.class), toStringConverter); + CONVERSION_MAP.put(new ConversionKey(Integer.class, String.class), toStringConverter); + CONVERSION_MAP.put(new ConversionKey(Long.class, String.class), toStringConverter); + CONVERSION_MAP.put(new ConversionKey(Float.class, String.class), toStringConverter); + CONVERSION_MAP.put(new ConversionKey(Double.class, String.class), toStringConverter); + + CONVERSION_MAP.put(new ConversionKey(String.class, Boolean.class), new Converter() { + @Override + public Object convert(Object value) { + return Boolean.valueOf((String) value); + } + }); + CONVERSION_MAP.put(new ConversionKey(String.class, Byte.class), new Converter() { + @Override + public Object convert(Object value) { + return Byte.valueOf((String) value); + } + }); + CONVERSION_MAP.put(new ConversionKey(String.class, Short.class), new Converter() { + @Override + public Object convert(Object value) { + return Short.valueOf((String) value); + } + }); + CONVERSION_MAP.put(new ConversionKey(String.class, Integer.class), new Converter() { + @Override + public Object convert(Object value) { + return Integer.valueOf((String) value); + } + }); + CONVERSION_MAP.put(new ConversionKey(String.class, Long.class), new Converter() { + @Override + public Object convert(Object value) { + return Long.valueOf((String) value); + } + }); + CONVERSION_MAP.put(new ConversionKey(String.class, Float.class), new Converter() { + @Override + public Object convert(Object value) { + return Float.valueOf((String) value); + } + }); + CONVERSION_MAP.put(new ConversionKey(String.class, Double.class), new Converter() { + @Override + public Object convert(Object value) { + return Double.valueOf((String) value); + } + }); + + Converter longConverter = new Converter() { + @Override + public Object convert(Object value) { + return Long.valueOf(((Number) value).longValue()); + } + }; + CONVERSION_MAP.put(new ConversionKey(Byte.class, Long.class), longConverter); + CONVERSION_MAP.put(new ConversionKey(Short.class, Long.class), longConverter); + CONVERSION_MAP.put(new ConversionKey(Integer.class, Long.class), longConverter); + CONVERSION_MAP.put(new ConversionKey(Date.class, Long.class), new Converter() { + @Override + public Object convert(Object value) { + return Long.valueOf(((Date) value).getTime()); + } + }); + + Converter intConverter = new Converter() { + @Override + public Object convert(Object value) { + return Integer.valueOf(((Number) value).intValue()); + } + }; + CONVERSION_MAP.put(new ConversionKey(Byte.class, Integer.class), intConverter); + CONVERSION_MAP.put(new ConversionKey(Short.class, Integer.class), intConverter); + + CONVERSION_MAP.put(new ConversionKey(Byte.class, Short.class), new Converter() { + @Override + public Object convert(Object value) { + return Short.valueOf(((Number) value).shortValue()); + } + }); + + CONVERSION_MAP.put(new ConversionKey(Float.class, Double.class), new Converter() { + @Override + public Object convert(Object value) { + return new Double(((Number) value).doubleValue()); + } + }); + } + + public static Object convert(Object value, Class toClass) { + + assert value != null && toClass != null; + + if (value.getClass() == toClass) { + return value; + } + + Class fromClass = value.getClass(); + + if (fromClass.isPrimitive()) { + fromClass = convertPrimitiveTypeToWrapperType(fromClass); + } + + if (toClass.isPrimitive()) { + toClass = convertPrimitiveTypeToWrapperType(toClass); + } + + Converter c = CONVERSION_MAP.get(new ConversionKey(fromClass, toClass)); + if (c == null) { + return null; + } + + return c.convert(value); + } + + private static Class convertPrimitiveTypeToWrapperType(Class type) { + Class rc = type; + if (type.isPrimitive()) { + if (type == int.class) { + rc = Integer.class; + } + else if (type == long.class) { + rc = Long.class; + } + else if (type == double.class) { + rc = Double.class; + } + else if (type == float.class) { + rc = Float.class; + } + else if (type == short.class) { + rc = Short.class; + } + else if (type == byte.class) { + rc = Byte.class; + } + else if (type == boolean.class) { + rc = Boolean.class; + } + } + + return rc; + } + + private TypeConversionSupport() { + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/UnmodifiableConnection.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/UnmodifiableConnection.java new file mode 100644 index 0000000000..32003a4fb5 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/UnmodifiableConnection.java @@ -0,0 +1,202 @@ +/** + * 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 org.apache.activemq.transport.amqp.client.util; + +import java.util.EnumSet; +import java.util.Map; + +import org.apache.qpid.proton.amqp.Symbol; +import org.apache.qpid.proton.amqp.transport.ErrorCondition; +import org.apache.qpid.proton.engine.Collector; +import org.apache.qpid.proton.engine.Connection; +import org.apache.qpid.proton.engine.Delivery; +import org.apache.qpid.proton.engine.EndpointState; +import org.apache.qpid.proton.engine.Link; +import org.apache.qpid.proton.engine.Record; +import org.apache.qpid.proton.engine.Session; +import org.apache.qpid.proton.engine.Transport; +import org.apache.qpid.proton.reactor.Reactor; + +/** + * Unmodifiable Connection wrapper used to prevent test code from accidentally + * modifying Connection state. + */ +public class UnmodifiableConnection implements Connection { + + private final Connection connection; + + public UnmodifiableConnection(Connection connection) { + this.connection = connection; + } + + @Override + public EndpointState getLocalState() { + return connection.getLocalState(); + } + + @Override + public EndpointState getRemoteState() { + return connection.getRemoteState(); + } + + @Override + public ErrorCondition getCondition() { + return connection.getCondition(); + } + + @Override + public void setCondition(ErrorCondition condition) { + throw new UnsupportedOperationException("Cannot alter the Connection"); + } + + @Override + public ErrorCondition getRemoteCondition() { + return connection.getRemoteCondition(); + } + + @Override + public void free() { + throw new UnsupportedOperationException("Cannot alter the Connection"); + } + + @Override + public void open() { + throw new UnsupportedOperationException("Cannot alter the Connection"); + } + + @Override + public void close() { + throw new UnsupportedOperationException("Cannot alter the Connection"); + } + + @Override + public Session session() { + throw new UnsupportedOperationException("Cannot alter the Connection"); + } + + @Override + public Session sessionHead(EnumSet local, EnumSet remote) { + Session head = connection.sessionHead(local, remote); + if (head != null) { + head = new UnmodifiableSession(head); + } + + return head; + } + + @Override + public Link linkHead(EnumSet local, EnumSet remote) { + // TODO - If implemented this method should return an unmodifiable link isntance. + return null; + } + + @Override + public Delivery getWorkHead() { + // TODO - If implemented this method should return an unmodifiable delivery isntance. + return null; + } + + @Override + public void setContainer(String container) { + throw new UnsupportedOperationException("Cannot alter the Connection"); + } + + @Override + public void setHostname(String hostname) { + throw new UnsupportedOperationException("Cannot alter the Connection"); + } + + @Override + public String getHostname() { + return connection.getHostname(); + } + + @Override + public String getRemoteContainer() { + return connection.getRemoteContainer(); + } + + @Override + public String getRemoteHostname() { + return connection.getRemoteHostname(); + } + + @Override + public void setOfferedCapabilities(Symbol[] capabilities) { + throw new UnsupportedOperationException("Cannot alter the Connection"); + } + + @Override + public void setDesiredCapabilities(Symbol[] capabilities) { + throw new UnsupportedOperationException("Cannot alter the Connection"); + } + + @Override + public Symbol[] getRemoteOfferedCapabilities() { + return connection.getRemoteOfferedCapabilities(); + } + + @Override + public Symbol[] getRemoteDesiredCapabilities() { + return connection.getRemoteDesiredCapabilities(); + } + + @Override + public Map getRemoteProperties() { + return connection.getRemoteProperties(); + } + + @Override + public void setProperties(Map properties) { + throw new UnsupportedOperationException("Cannot alter the Connection"); + } + + @Override + public Object getContext() { + return connection.getContext(); + } + + @Override + public void setContext(Object context) { + throw new UnsupportedOperationException("Cannot alter the Connection"); + } + + @Override + public void collect(Collector collector) { + throw new UnsupportedOperationException("Cannot alter the Connection"); + } + + @Override + public String getContainer() { + return connection.getContainer(); + } + + @Override + public Transport getTransport() { + return new UnmodifiableTransport(connection.getTransport()); + } + + @Override + public Record attachments() { + return connection.attachments(); + } + + @Override + public Reactor getReactor() { + return connection.getReactor(); + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/UnmodifiableDelivery.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/UnmodifiableDelivery.java new file mode 100644 index 0000000000..9f48b41f98 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/UnmodifiableDelivery.java @@ -0,0 +1,170 @@ +/** + * 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 org.apache.activemq.transport.amqp.client.util; + +import org.apache.qpid.proton.amqp.transport.DeliveryState; +import org.apache.qpid.proton.engine.Delivery; +import org.apache.qpid.proton.engine.Link; +import org.apache.qpid.proton.engine.Receiver; +import org.apache.qpid.proton.engine.Record; +import org.apache.qpid.proton.engine.Sender; + +/** + * Unmodifiable Delivery wrapper used to prevent test code from accidentally + * modifying Delivery state. + */ +public class UnmodifiableDelivery implements Delivery { + + private final Delivery delivery; + + public UnmodifiableDelivery(Delivery delivery) { + this.delivery = delivery; + } + + @Override + public byte[] getTag() { + return delivery.getTag(); + } + + @Override + public Link getLink() { + if (delivery.getLink() instanceof Sender) { + return new UnmodifiableSender((Sender) delivery.getLink()); + } + else if (delivery.getLink() instanceof Receiver) { + return new UnmodifiableReceiver((Receiver) delivery.getLink()); + } + else { + throw new IllegalStateException("Delivery has unknown link type"); + } + } + + @Override + public DeliveryState getLocalState() { + return delivery.getLocalState(); + } + + @Override + public DeliveryState getRemoteState() { + return delivery.getRemoteState(); + } + + @Override + public int getMessageFormat() { + return delivery.getMessageFormat(); + } + + @Override + public void disposition(DeliveryState state) { + throw new UnsupportedOperationException("Cannot alter the Delivery state"); + } + + @Override + public void settle() { + throw new UnsupportedOperationException("Cannot alter the Delivery state"); + } + + @Override + public boolean isSettled() { + return delivery.isSettled(); + } + + @Override + public boolean remotelySettled() { + return delivery.remotelySettled(); + } + + @Override + public void free() { + throw new UnsupportedOperationException("Cannot alter the Delivery state"); + } + + @Override + public Delivery getWorkNext() { + return new UnmodifiableDelivery(delivery.getWorkNext()); + } + + @Override + public Delivery next() { + return new UnmodifiableDelivery(delivery.next()); + } + + @Override + public boolean isWritable() { + return delivery.isWritable(); + } + + @Override + public boolean isReadable() { + return delivery.isReadable(); + } + + @Override + public void setContext(Object o) { + throw new UnsupportedOperationException("Cannot alter the Delivery state"); + } + + @Override + public Object getContext() { + return delivery.getContext(); + } + + @Override + public boolean isUpdated() { + return delivery.isUpdated(); + } + + @Override + public void clear() { + throw new UnsupportedOperationException("Cannot alter the Delivery state"); + } + + @Override + public boolean isPartial() { + return delivery.isPartial(); + } + + @Override + public int pending() { + return delivery.pending(); + } + + @Override + public boolean isBuffered() { + return delivery.isBuffered(); + } + + @Override + public Record attachments() { + return delivery.attachments(); + } + + @Override + public DeliveryState getDefaultDeliveryState() { + return delivery.getDefaultDeliveryState(); + } + + @Override + public void setDefaultDeliveryState(DeliveryState state) { + throw new UnsupportedOperationException("Cannot alter the Delivery"); + } + + @Override + public void setMessageFormat(int messageFormat) { + throw new UnsupportedOperationException("Cannot alter the Delivery"); + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/UnmodifiableLink.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/UnmodifiableLink.java new file mode 100644 index 0000000000..a58bfe76d3 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/UnmodifiableLink.java @@ -0,0 +1,276 @@ +/** + * 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 org.apache.activemq.transport.amqp.client.util; + +import java.util.EnumSet; +import java.util.Map; + +import org.apache.qpid.proton.amqp.Symbol; +import org.apache.qpid.proton.amqp.transport.ErrorCondition; +import org.apache.qpid.proton.amqp.transport.ReceiverSettleMode; +import org.apache.qpid.proton.amqp.transport.SenderSettleMode; +import org.apache.qpid.proton.amqp.transport.Source; +import org.apache.qpid.proton.amqp.transport.Target; +import org.apache.qpid.proton.engine.Delivery; +import org.apache.qpid.proton.engine.EndpointState; +import org.apache.qpid.proton.engine.Link; +import org.apache.qpid.proton.engine.Receiver; +import org.apache.qpid.proton.engine.Record; +import org.apache.qpid.proton.engine.Sender; +import org.apache.qpid.proton.engine.Session; + +/** + * Unmodifiable Session wrapper used to prevent test code from accidentally + * modifying Session state. + */ +public class UnmodifiableLink implements Link { + + private final Link link; + + public UnmodifiableLink(Link link) { + this.link = link; + } + + @Override + public EndpointState getLocalState() { + return link.getLocalState(); + } + + @Override + public EndpointState getRemoteState() { + return link.getRemoteState(); + } + + @Override + public ErrorCondition getCondition() { + return link.getCondition(); + } + + @Override + public void setCondition(ErrorCondition condition) { + throw new UnsupportedOperationException("Cannot alter the Link state"); + } + + @Override + public ErrorCondition getRemoteCondition() { + return link.getRemoteCondition(); + } + + @Override + public void free() { + throw new UnsupportedOperationException("Cannot alter the Link state"); + } + + @Override + public void open() { + throw new UnsupportedOperationException("Cannot alter the Link state"); + } + + @Override + public void close() { + throw new UnsupportedOperationException("Cannot alter the Link state"); + } + + @Override + public void setContext(Object o) { + throw new UnsupportedOperationException("Cannot alter the Link state"); + } + + @Override + public Object getContext() { + return link.getContext(); + } + + @Override + public String getName() { + return link.getName(); + } + + @Override + public Delivery delivery(byte[] tag) { + throw new UnsupportedOperationException("Cannot alter the Link state"); + } + + @Override + public Delivery delivery(byte[] tag, int offset, int length) { + throw new UnsupportedOperationException("Cannot alter the Link state"); + } + + @Override + public Delivery head() { + return new UnmodifiableDelivery(link.head()); + } + + @Override + public Delivery current() { + return new UnmodifiableDelivery(link.current()); + } + + @Override + public boolean advance() { + throw new UnsupportedOperationException("Cannot alter the Link state"); + } + + @Override + public Source getSource() { + // TODO Figure out a simple way to wrap the odd Source types in Proton-J + return link.getSource(); + } + + @Override + public Target getTarget() { + // TODO Figure out a simple way to wrap the odd Source types in Proton-J + return link.getTarget(); + } + + @Override + public void setSource(Source address) { + throw new UnsupportedOperationException("Cannot alter the Link state"); + } + + @Override + public void setTarget(Target address) { + throw new UnsupportedOperationException("Cannot alter the Link state"); + } + + @Override + public Source getRemoteSource() { + // TODO Figure out a simple way to wrap the odd Source types in Proton-J + return link.getRemoteSource(); + } + + @Override + public Target getRemoteTarget() { + // TODO Figure out a simple way to wrap the odd Target types in Proton-J + return link.getRemoteTarget(); + } + + @Override + public Link next(EnumSet local, EnumSet remote) { + Link next = link.next(local, remote); + + if (next != null) { + if (next instanceof Sender) { + next = new UnmodifiableSender((Sender) next); + } + else { + next = new UnmodifiableReceiver((Receiver) next); + } + } + + return next; + } + + @Override + public int getCredit() { + return link.getCredit(); + } + + @Override + public int getQueued() { + return link.getQueued(); + } + + @Override + public int getUnsettled() { + return link.getUnsettled(); + } + + @Override + public Session getSession() { + return new UnmodifiableSession(link.getSession()); + } + + @Override + public SenderSettleMode getSenderSettleMode() { + return link.getSenderSettleMode(); + } + + @Override + public void setSenderSettleMode(SenderSettleMode senderSettleMode) { + throw new UnsupportedOperationException("Cannot alter the Link state"); + } + + @Override + public SenderSettleMode getRemoteSenderSettleMode() { + return link.getRemoteSenderSettleMode(); + } + + @Override + public ReceiverSettleMode getReceiverSettleMode() { + return link.getReceiverSettleMode(); + } + + @Override + public void setReceiverSettleMode(ReceiverSettleMode receiverSettleMode) { + throw new UnsupportedOperationException("Cannot alter the Link state"); + } + + @Override + public ReceiverSettleMode getRemoteReceiverSettleMode() { + return link.getRemoteReceiverSettleMode(); + } + + @Override + public void setRemoteSenderSettleMode(SenderSettleMode remoteSenderSettleMode) { + throw new UnsupportedOperationException("Cannot alter the Link state"); + } + + @Override + public int drained() { + return link.drained(); // TODO - Is this a mutating call? + } + + @Override + public int getRemoteCredit() { + return link.getRemoteCredit(); + } + + @Override + public boolean getDrain() { + return link.getDrain(); + } + + @Override + public void detach() { + throw new UnsupportedOperationException("Cannot alter the Link state"); + } + + @Override + public boolean detached() { + return link.detached(); + } + + public Record attachments() { + return link.attachments(); + } + + @Override + public Map getProperties() { + return link.getProperties(); + } + + @Override + public void setProperties(Map properties) { + throw new UnsupportedOperationException("Cannot alter the Link state"); + } + + @Override + public Map getRemoteProperties() { + return link.getRemoteProperties(); + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/UnmodifiableReceiver.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/UnmodifiableReceiver.java new file mode 100644 index 0000000000..92760db337 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/UnmodifiableReceiver.java @@ -0,0 +1,59 @@ +/** + * 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 org.apache.activemq.transport.amqp.client.util; + +import org.apache.qpid.proton.engine.Receiver; + +/** + * Unmodifiable Receiver wrapper used to prevent test code from accidentally + * modifying Receiver state. + */ +public class UnmodifiableReceiver extends UnmodifiableLink implements Receiver { + + private final Receiver receiver; + + public UnmodifiableReceiver(Receiver receiver) { + super(receiver); + + this.receiver = receiver; + } + + @Override + public void flow(int credits) { + throw new UnsupportedOperationException("Cannot alter the Link state"); + } + + @Override + public int recv(byte[] bytes, int offset, int size) { + throw new UnsupportedOperationException("Cannot alter the Link state"); + } + + @Override + public void drain(int credit) { + throw new UnsupportedOperationException("Cannot alter the Link state"); + } + + @Override + public boolean draining() { + return receiver.draining(); + } + + @Override + public void setDrain(boolean drain) { + throw new UnsupportedOperationException("Cannot alter the Link state"); + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/UnmodifiableSender.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/UnmodifiableSender.java new file mode 100644 index 0000000000..89742cb517 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/UnmodifiableSender.java @@ -0,0 +1,45 @@ +/** + * 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 org.apache.activemq.transport.amqp.client.util; + +import org.apache.qpid.proton.engine.Sender; + +/** + * Unmodifiable Sender wrapper used to prevent test code from accidentally + * modifying Sender state. + */ +public class UnmodifiableSender extends UnmodifiableLink implements Sender { + + public UnmodifiableSender(Sender sender) { + super(sender); + } + + @Override + public void offer(int credits) { + throw new UnsupportedOperationException("Cannot alter the Link state"); + } + + @Override + public int send(byte[] bytes, int offset, int length) { + throw new UnsupportedOperationException("Cannot alter the Link state"); + } + + @Override + public void abort() { + throw new UnsupportedOperationException("Cannot alter the Link state"); + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/UnmodifiableSession.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/UnmodifiableSession.java new file mode 100644 index 0000000000..a44028e0e5 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/UnmodifiableSession.java @@ -0,0 +1,150 @@ +/** + * 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 org.apache.activemq.transport.amqp.client.util; + +import java.util.EnumSet; + +import org.apache.qpid.proton.amqp.transport.ErrorCondition; +import org.apache.qpid.proton.engine.Connection; +import org.apache.qpid.proton.engine.EndpointState; +import org.apache.qpid.proton.engine.Receiver; +import org.apache.qpid.proton.engine.Record; +import org.apache.qpid.proton.engine.Sender; +import org.apache.qpid.proton.engine.Session; + +/** + * Unmodifiable Session wrapper used to prevent test code from accidentally + * modifying Session state. + */ +public class UnmodifiableSession implements Session { + + private final Session session; + + public UnmodifiableSession(Session session) { + this.session = session; + } + + @Override + public EndpointState getLocalState() { + return session.getLocalState(); + } + + @Override + public EndpointState getRemoteState() { + return session.getRemoteState(); + } + + @Override + public ErrorCondition getCondition() { + return session.getCondition(); + } + + @Override + public void setCondition(ErrorCondition condition) { + throw new UnsupportedOperationException("Cannot alter the Session"); + } + + @Override + public ErrorCondition getRemoteCondition() { + return session.getRemoteCondition(); + } + + @Override + public void free() { + throw new UnsupportedOperationException("Cannot alter the Session"); + } + + @Override + public void open() { + throw new UnsupportedOperationException("Cannot alter the Session"); + } + + @Override + public void close() { + throw new UnsupportedOperationException("Cannot alter the Session"); + } + + @Override + public void setContext(Object o) { + throw new UnsupportedOperationException("Cannot alter the Session"); + } + + @Override + public Object getContext() { + return session.getContext(); + } + + @Override + public Sender sender(String name) { + throw new UnsupportedOperationException("Cannot alter the Session"); + } + + @Override + public Receiver receiver(String name) { + throw new UnsupportedOperationException("Cannot alter the Session"); + } + + @Override + public Session next(EnumSet local, EnumSet remote) { + Session next = session.next(local, remote); + if (next != null) { + next = new UnmodifiableSession(next); + } + + return next; + } + + @Override + public Connection getConnection() { + return new UnmodifiableConnection(session.getConnection()); + } + + @Override + public int getIncomingCapacity() { + return session.getIncomingCapacity(); + } + + @Override + public void setIncomingCapacity(int bytes) { + throw new UnsupportedOperationException("Cannot alter the Session"); + } + + @Override + public int getIncomingBytes() { + return session.getIncomingBytes(); + } + + @Override + public int getOutgoingBytes() { + return session.getOutgoingBytes(); + } + + @Override + public Record attachments() { + return session.attachments(); + } + + @Override + public long getOutgoingWindow() { + return session.getOutgoingWindow(); + } + + @Override + public void setOutgoingWindow(long outgoingWindowSize) { + throw new UnsupportedOperationException("Cannot alter the Session"); + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/UnmodifiableTransport.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/UnmodifiableTransport.java new file mode 100644 index 0000000000..5e305f4d11 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/UnmodifiableTransport.java @@ -0,0 +1,274 @@ +/** + * 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 org.apache.activemq.transport.amqp.client.util; + +import java.nio.ByteBuffer; + +import org.apache.qpid.proton.amqp.transport.ErrorCondition; +import org.apache.qpid.proton.engine.Connection; +import org.apache.qpid.proton.engine.EndpointState; +import org.apache.qpid.proton.engine.Record; +import org.apache.qpid.proton.engine.Sasl; +import org.apache.qpid.proton.engine.Ssl; +import org.apache.qpid.proton.engine.SslDomain; +import org.apache.qpid.proton.engine.SslPeerDetails; +import org.apache.qpid.proton.engine.Transport; +import org.apache.qpid.proton.engine.TransportException; +import org.apache.qpid.proton.engine.TransportResult; + +/** + * Unmodifiable Transport wrapper used to prevent test code from accidentally + * modifying Transport state. + */ +public class UnmodifiableTransport implements Transport { + + private final Transport transport; + + public UnmodifiableTransport(Transport transport) { + this.transport = transport; + } + + @Override + public void close() { + throw new UnsupportedOperationException("Cannot alter the Transport"); + } + + @Override + public void free() { + throw new UnsupportedOperationException("Cannot alter the Transport"); + } + + @Override + public Object getContext() { + return null; + } + + @Override + public EndpointState getLocalState() { + return transport.getLocalState(); + } + + @Override + public ErrorCondition getRemoteCondition() { + return transport.getRemoteCondition(); + } + + @Override + public EndpointState getRemoteState() { + return transport.getRemoteState(); + } + + @Override + public void open() { + throw new UnsupportedOperationException("Cannot alter the Transport"); + } + + @Override + public void setCondition(ErrorCondition arg0) { + throw new UnsupportedOperationException("Cannot alter the Transport"); + } + + @Override + public void setContext(Object arg0) { + throw new UnsupportedOperationException("Cannot alter the Transport"); + } + + @Override + public void bind(Connection arg0) { + throw new UnsupportedOperationException("Cannot alter the Transport"); + } + + @Override + public int capacity() { + return transport.capacity(); + } + + @Override + public void close_head() { + throw new UnsupportedOperationException("Cannot alter the Transport"); + } + + @Override + public void close_tail() { + throw new UnsupportedOperationException("Cannot alter the Transport"); + } + + @Override + public int getChannelMax() { + return transport.getChannelMax(); + } + + @Override + public ErrorCondition getCondition() { + return transport.getCondition(); + } + + @Override + public int getIdleTimeout() { + return transport.getIdleTimeout(); + } + + @Override + public ByteBuffer getInputBuffer() { + return null; + } + + @Override + public int getMaxFrameSize() { + return transport.getMaxFrameSize(); + } + + @Override + public ByteBuffer getOutputBuffer() { + return null; + } + + @Override + public int getRemoteChannelMax() { + return transport.getRemoteChannelMax(); + } + + @Override + public int getRemoteIdleTimeout() { + return transport.getRemoteIdleTimeout(); + } + + @Override + public int getRemoteMaxFrameSize() { + return transport.getRemoteMaxFrameSize(); + } + + @Override + public ByteBuffer head() { + return null; + } + + @Override + public int input(byte[] arg0, int arg1, int arg2) { + throw new UnsupportedOperationException("Cannot alter the Transport"); + } + + @Override + public boolean isClosed() { + return transport.isClosed(); + } + + @Override + public int output(byte[] arg0, int arg1, int arg2) { + throw new UnsupportedOperationException("Cannot alter the Transport"); + } + + @Override + public void outputConsumed() { + throw new UnsupportedOperationException("Cannot alter the Transport"); + } + + @Override + public int pending() { + return transport.pending(); + } + + @Override + public void pop(int arg0) { + throw new UnsupportedOperationException("Cannot alter the Transport"); + } + + @Override + public void process() throws TransportException { + throw new UnsupportedOperationException("Cannot alter the Transport"); + } + + @Override + public TransportResult processInput() { + throw new UnsupportedOperationException("Cannot alter the Transport"); + } + + @Override + public Sasl sasl() throws IllegalStateException { + throw new UnsupportedOperationException("Cannot alter the Transport"); + } + + @Override + public void setChannelMax(int arg0) { + throw new UnsupportedOperationException("Cannot alter the Transport"); + } + + @Override + public void setIdleTimeout(int arg0) { + throw new UnsupportedOperationException("Cannot alter the Transport"); + } + + @Override + public void setMaxFrameSize(int arg0) { + throw new UnsupportedOperationException("Cannot alter the Transport"); + } + + @Override + public Ssl ssl(SslDomain arg0) { + throw new UnsupportedOperationException("Cannot alter the Transport"); + } + + @Override + public Ssl ssl(SslDomain arg0, SslPeerDetails arg1) { + throw new UnsupportedOperationException("Cannot alter the Transport"); + } + + @Override + public ByteBuffer tail() { + return null; + } + + @Override + public long tick(long arg0) { + throw new UnsupportedOperationException("Cannot alter the Transport"); + } + + @Override + public void trace(int arg0) { + throw new UnsupportedOperationException("Cannot alter the Transport"); + } + + @Override + public void unbind() { + throw new UnsupportedOperationException("Cannot alter the Transport"); + } + + @Override + public Record attachments() { + return transport.attachments(); + } + + @Override + public long getFramesInput() { + return transport.getFramesInput(); + } + + @Override + public long getFramesOutput() { + return transport.getFramesOutput(); + } + + @Override + public void setEmitFlowEventOnSend(boolean emitFlowEventOnSend) { + throw new UnsupportedOperationException("Cannot alter the Transport"); + } + + @Override + public boolean isEmitFlowEventOnSend() { + return transport.isEmitFlowEventOnSend(); + } +} diff --git a/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/WrappedAsyncResult.java b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/WrappedAsyncResult.java new file mode 100644 index 0000000000..bfe9a80773 --- /dev/null +++ b/tests/artemis-test-support/src/main/java/org/apache/activemq/transport/amqp/client/util/WrappedAsyncResult.java @@ -0,0 +1,59 @@ +/** + * 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 org.apache.activemq.transport.amqp.client.util; + +/** + * Base class used to wrap one AsyncResult with another. + */ +public abstract class WrappedAsyncResult implements AsyncResult { + + protected final AsyncResult wrapped; + + /** + * Create a new WrappedAsyncResult for the target AsyncResult + */ + public WrappedAsyncResult(AsyncResult wrapped) { + this.wrapped = wrapped; + } + + @Override + public void onFailure(Throwable result) { + if (wrapped != null) { + wrapped.onFailure(result); + } + } + + @Override + public void onSuccess() { + if (wrapped != null) { + wrapped.onSuccess(); + } + } + + @Override + public boolean isComplete() { + if (wrapped != null) { + return wrapped.isComplete(); + } + + return false; + } + + public AsyncResult getWrappedRequest() { + return wrapped; + } +} diff --git a/tests/integration-tests/pom.xml b/tests/integration-tests/pom.xml index 752e288235..5d7617cc78 100644 --- a/tests/integration-tests/pom.xml +++ b/tests/integration-tests/pom.xml @@ -340,6 +340,11 @@ org.apache.karaf.shell.console ${karaf.version} + + org.apache.activemq.tests + artemis-test-support + ${project.version} + diff --git a/tests/pom.xml b/tests/pom.xml index 6a9c0003ed..a2efeacdf9 100644 --- a/tests/pom.xml +++ b/tests/pom.xml @@ -46,6 +46,13 @@ 1.2 + + org.apache.qpid + qpid-jms-client + 0.10.0 + + + @@ -122,5 +129,6 @@ soak-tests stress-tests performance-tests + artemis-test-support