/* * Copyright 2014 Jeanfrancois Arcand * * Licensed 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.atmosphere.stomp.interceptor; import org.atmosphere.cpr.Action; import org.atmosphere.cpr.AtmosphereConfig; import org.atmosphere.cpr.AtmosphereFramework; import org.atmosphere.cpr.AtmosphereHandler; import org.atmosphere.cpr.AtmosphereInterceptorAdapter; import org.atmosphere.cpr.AtmosphereResource; import org.atmosphere.cpr.AtmosphereResourceSessionFactory; import org.atmosphere.cpr.BroadcastFilterLifecycle; import org.atmosphere.handler.AbstractReflectorAtmosphereHandler; import org.atmosphere.stomp.StompBroadcastFilter; import org.atmosphere.stomp.StompInterceptor; import org.atmosphere.stomp.Subscriptions; import org.atmosphere.stomp.protocol.Frame; import org.atmosphere.stomp.protocol.Header; import org.atmosphere.stomp.protocol.ParseException; import org.atmosphere.stomp.protocol.StompFormat; import org.atmosphere.stomp.protocol.StompFormatImpl; import org.atmosphere.util.IOUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.util.Arrays; import java.util.HashMap; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; /** * <p> * This interceptor reads the frames and parse it thanks to the {@link org.atmosphere.stomp.protocol.StompFormat}. When * the message is parsed, the interceptor delegates an appropriate treatment to a {@link StompInterceptor}. * </p> * * <p> * This interceptor should be executed before the {@link org.atmosphere.interceptor.AtmosphereResourceLifecycleInterceptor} * to suspends the connection. Then it could add any {@link AtmosphereResource} to a {@link org.atmosphere.cpr.Broadcaster} if necessary. * </p> * * @author Guillaume DROUET * @since 0.1 * @version 1.0 */ public class FrameInterceptor extends AtmosphereInterceptorAdapter implements StompInterceptor { /** * <p> * This enum is dedicated to properties that represents a class to instantiate. * </p> * * @author Guillaume DROUET * @since 0.1 * @version 1.0 */ public enum PropertyClass { /** * Setting that specifies the {@link org.atmosphere.stomp.protocol.StompFormat} implementation class used by the interceptor. */ STOMP_FORMAT_CLASS("org.atmosphere.stomp.stompFormatClass", StompFormatImpl.class.getName()); /** * The logger. */ private final Logger logger = LoggerFactory.getLogger(getClass()); /** * The property name. */ private String propertyName; /** * The default implementation if property not set by user. */ private String defaultClass; /** * <p> * Builds a new enumeration. * </p> * * @param propertyName the property name * @param defaultClass the default implementation class */ private PropertyClass(final String propertyName, final String defaultClass) { this.propertyName = propertyName; this.defaultClass = defaultClass; } /** * <p> * Checks in the {@link AtmosphereConfig} if the {@link #propertyName} is defined as an init-param and instantiate * the appropriate class. * </p> * * <p> * If instantiation fails, the exception is logged and {@code null} is returned. * </p> * * @param desiredType the type to be returned * @param config the configuration that provides parameters * @param <T> the generic for modular call * @return the instance of the expected class, {@code null} if an error occurs */ public <T> T retrieve(final Class<T> desiredType, final AtmosphereConfig config) { final String initParameter = config.getInitParameter(this.propertyName); final String className = (initParameter != null) ? initParameter : defaultClass; try { final AtmosphereFramework fwk = config.framework(); final Object retval = fwk.newClassInstance(desiredType, desiredType.getClass().cast(Class.forName(className))); return desiredType.cast(retval); } catch (Exception e) { logger.error("Unable to initialize {}", getClass().getName(), e); return null; } } /** * {@inheritDoc} */ @Override public String toString() { return propertyName; } } /** * <p> * Inner class that wraps the {@link AtmosphereResource} during inspection to write frame and check the nature of * the operations. * </p> * * @author Guillaume DROUET * @since 0.3 * @version 1.0 * */ public class StompAtmosphereResource { /** * The wrapped resource. */ private final AtmosphereResource resource; /** * The frame that triggers the inspection. */ private final Frame frame; /** * If an error frame has been written. */ private boolean hasError; /** * <p> * Builds a new action. * </p> * * @param r the resource * @param f the frame */ public StompAtmosphereResource(final AtmosphereResource r, final Frame f) { resource = r; frame = f; } /** * <p> * Write a frame with its headers. * </p> * * @param a the action * @param headers the headers */ public void write(org.atmosphere.stomp.protocol.Action a, final Map<String, String> headers) { write(a, headers, null); } /** * <p> * Write a frame with its headers and a content. * </p> * * @param a the action * @param headers the headers * @param message the message */ public void write(final org.atmosphere.stomp.protocol.Action a, final Map<String, String> headers, final String message) { resource.write(stompFormat.format(new Frame(a, headers, message))); if (!hasError) { hasError = org.atmosphere.stomp.protocol.Action.ERROR.equals(a); } } /** * <p> * Sends a receipt if the headers indicate that the client expect a response from the server when the message * has been consumed successfully. No receipt will be sent if an error has occurred during inspection and in * case of connection step. * </p> */ private void receipt() { if (!hasError && !org.atmosphere.stomp.protocol.Action.CONNECT.equals(frame.getAction())) { final String receiptId = frame.getHeaders().get(Header.RECEIPT_ID); if (receiptId != null) { final Map<String, String> headers = new HashMap<String, String>(); headers.put(Header.RECEIPT_ID, frame.getHeaders().get(Header.RECEIPT_ID)); write(org.atmosphere.stomp.protocol.Action.RECEIPT, headers); } } } /** * <p> * Gets the wrapped resource. * </p> * * @return the resource */ public AtmosphereResource getResource() { return resource; } } /** * The attribute name this interceptor uses to inject a parsed body in the request when it is extracted from the frame. */ public static final String STOMP_MESSAGE_BODY = "org.atmosphere.stomp.body"; /** * The logger. */ private final Logger logger = LoggerFactory.getLogger(getClass()); /** * The framework extracted from the {@link AtmosphereConfig} when {@link #configure(AtmosphereConfig)} is called. */ private AtmosphereFramework framework; /** * The formatter that can encode and decode frames. */ private StompFormat stompFormat; /** * The {@link AtmosphereResourceSessionFactory}. */ private AtmosphereResourceSessionFactory arsf; /** * The interceptors used to dispatch the frame. */ private Map<org.atmosphere.stomp.protocol.Action, StompInterceptor> interceptors; /** * {@inheritDoc} */ @Override public void configure(final AtmosphereConfig config) { framework = config.framework(); arsf = config.sessionFactory(); setStompFormat(PropertyClass.STOMP_FORMAT_CLASS.retrieve(StompFormat.class, config)); try { // TODO: user must map AtmosphereServlet to /stomp in web.xml, can we offer a chance to set a custom location ? framework.addAtmosphereHandler("/stomp", framework.newClassInstance(AtmosphereHandler.class, AbstractReflectorAtmosphereHandler.Default.class)); interceptors = new ConcurrentHashMap<org.atmosphere.stomp.protocol.Action, StompInterceptor>(); configureInterceptor(config, ConnectInterceptor.class, org.atmosphere.stomp.protocol.Action.CONNECT, org.atmosphere.stomp.protocol.Action.STOMP, org.atmosphere.stomp.protocol.Action.NULL); configureInterceptor(config, SubscribeInterceptor.class, org.atmosphere.stomp.protocol.Action.SUBSCRIBE); configureInterceptor(config, UnsubscribeInterceptor.class, org.atmosphere.stomp.protocol.Action.UNSUBSCRIBE); configureInterceptor(config, SendInterceptor.class, org.atmosphere.stomp.protocol.Action.SEND); configureInterceptor(config, DisconnectInterceptor.class, org.atmosphere.stomp.protocol.Action.DISCONNECT); final BroadcastFilterLifecycle filter = framework.newClassInstance(BroadcastFilterLifecycle.class, StompBroadcastFilter.class); framework.broadcasterFilters(filter); filter.init(config); } catch (InstantiationException e) { logger.error("", e); } catch (IllegalAccessException e) { logger.error("", e); } } /** * {@inheritDoc} */ @Override public org.atmosphere.cpr.Action inspect(final AtmosphereResource r) { String body = null; try { body = IOUtils.readEntirelyAsString(r).toString(); // Let the global handler suspend the connection if no action is submitted if (body.length() == 0) { return Action.CONTINUE; } else if (Arrays.equals(body.getBytes(), ConnectInterceptor.STOMP_HEARTBEAT_DATA)) { // Particular case: the heartbeat is handled by the ConnectInterceptor final Frame f = new Frame(org.atmosphere.stomp.protocol.Action.NULL, new HashMap<String, String>()); return inspect(framework, f, new StompAtmosphereResource(r, f)); } else { final Frame frame = stompFormat.parse(body.substring(0, body.length() - 1)); final StompAtmosphereResource sar = new StompAtmosphereResource(r, frame); try { return inspect(framework, frame, sar); } finally { sar.receipt(); } } } catch (final IOException ioe) { logger.error("STOMP interceptor fails", ioe); } catch (final ParseException pe) { logger.error("Invalid STOMP string: {} ", body, pe); } return Action.CANCELLED; } /** * {@inheritDoc} */ @Override public void postInspect(final AtmosphereResource atmosphereResource) { // The client can reconnects while he has already subscribed different destinations // We need to add the new request to the associated broadcasters if (atmosphereResource.isSuspended()) { final Subscriptions s = Subscriptions.getFromSession(arsf.getSession(atmosphereResource)); final Set<String> destinations = s.getAllDestinations(); for (final String d : destinations) { framework.getAtmosphereConfig().getBroadcasterFactory().lookup(d).addAtmosphereResource(atmosphereResource); } } } /** * {@inheritDoc} */ @Override public Action inspect(final AtmosphereFramework framework, final Frame frame, final StompAtmosphereResource r) throws IOException { final StompInterceptor interceptor = interceptors.get(frame.getAction()); if (interceptor == null) { logger.warn("{} is not supported", frame.getAction().toString(), new UnsupportedOperationException()); return Action.CANCELLED; } return interceptor.inspect(framework, frame, r); } /** * <p> * Sets the {@link StompFormat} that wire frames. * </p> * * @param stompFormat the new formatter */ public void setStompFormat(final StompFormat stompFormat) { this.stompFormat = stompFormat; } /** * <p> * Adds the appropriate interceptor for each action. * </p> * * @param config the configuration * @param clazz the interceptor * @param action the actions * @throws InstantiationException if interceptor class can't be instantiated * @throws IllegalAccessException if interceptor class can't be instantiated */ private void configureInterceptor(final AtmosphereConfig config, final Class<? extends StompInterceptor> clazz, final org.atmosphere.stomp.protocol.Action ... action) throws InstantiationException, IllegalAccessException { final StompInterceptor interceptor = framework.newClassInstance(StompInterceptor.class, clazz); interceptor.configure(config); for (org.atmosphere.stomp.protocol.Action a : action) { interceptors.put(a, interceptor); } } }