diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/adapter/ConvertingEncoderDecoderSupport.java b/spring-websocket/src/main/java/org/springframework/web/socket/adapter/ConvertingEncoderDecoderSupport.java new file mode 100644 index 00000000000..5158eeac7ec --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/adapter/ConvertingEncoderDecoderSupport.java @@ -0,0 +1,247 @@ +/* + * Copyright 2002-2013 the original author or authors. + * + * 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.springframework.web.socket.adapter; + +import java.nio.ByteBuffer; + +import javax.websocket.DecodeException; +import javax.websocket.Decoder; +import javax.websocket.EncodeException; +import javax.websocket.Encoder; +import javax.websocket.EndpointConfig; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.GenericTypeResolver; +import org.springframework.core.convert.ConversionException; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.util.Assert; +import org.springframework.web.context.ContextLoader; + +/** + * Base class that can be used to implement a standard {@link javax.websocket.Encoder} + * and/or {@link javax.websocket.Decoder}. It provides encode and decode method + * implementations that delegate to a Spring {@link ConversionService}. + * + *

By default, this class looks up a {@link ConversionService} registered in the + * {@link #getApplicationContext() active ApplicationContext} under + * the name {@code 'webSocketConversionService'}. This works fine for both client + * and server endpoints, in a Servlet container environment. If not running in a + * Servlet container, subclasses will need to override the + * {@link #getConversionService()} method to provide an alternative lookup strategy. + * + *

Subclasses can extend this class and should also implement one or + * both of {@link javax.websocket.Encoder} and {@link javax.websocket.Decoder}. + * For convenience {@link ConvertingEncoderDecoderSupport.BinaryEncoder}, + * {@link ConvertingEncoderDecoderSupport.BinaryDecoder}, + * {@link ConvertingEncoderDecoderSupport.TextEncoder} and + * {@link ConvertingEncoderDecoderSupport.TextDecoder} subclasses are provided. + * + *

Since JSR-356 only allows Encoder/Decoder to be registered by type, instances + * of this class are therefore managed by the WebSocket runtime, and do not need to + * be registered as Spring Beans. They can, however, by injected with Spring-managed + * dependencies via {@link Autowired @Autowire}. + * + *

Converters to convert between the {@link #getType() type} and {@code String} or + * {@code ByteBuffer} should be registered. + * + * @author Phillip Webb + * @since 4.0 + * + * @param The type being converted to (for Encoder) or from (for Decoder) + * @param The WebSocket message type ({@link String} or {@link ByteBuffer}) + * + * @see ConvertingEncoderDecoderSupport.BinaryEncoder + * @see ConvertingEncoderDecoderSupport.BinaryDecoder + * @see ConvertingEncoderDecoderSupport.TextEncoder + * @see ConvertingEncoderDecoderSupport.TextDecoder + */ +public abstract class ConvertingEncoderDecoderSupport { + + private static final String CONVERSION_SERVICE_BEAN_NAME = "webSocketConversionService"; + + + /** + * @see javax.websocket.Encoder#init(EndpointConfig) + * @see javax.websocket.Decoder#init(EndpointConfig) + */ + public void init(EndpointConfig config) { + ApplicationContext applicationContext = getApplicationContext(); + if (applicationContext != null && applicationContext instanceof ConfigurableApplicationContext) { + ConfigurableListableBeanFactory beanFactory = + ((ConfigurableApplicationContext) applicationContext).getBeanFactory(); + beanFactory.autowireBean(this); + } + } + + /** + * @see javax.websocket.Encoder#destroy() + * @see javax.websocket.Decoder#destroy() + */ + public void destroy() { + } + + /** + * Strategy method used to obtain the {@link ConversionService}. By default this + * method expects a bean named {@code 'webSocketConversionService'} in the + * {@link #getApplicationContext() active ApplicationContext}. + * @return the {@link ConversionService} (never null) + */ + protected ConversionService getConversionService() { + ApplicationContext applicationContext = getApplicationContext(); + Assert.state(applicationContext != null, + "Unable to locate the Spring ApplicationContext"); + try { + return applicationContext.getBean(CONVERSION_SERVICE_BEAN_NAME, ConversionService.class); + } + catch (BeansException ex) { + throw new IllegalStateException( + "Unable to find ConversionService, please configure a '" + + CONVERSION_SERVICE_BEAN_NAME + "' or override getConversionService()", ex); + } + } + + /** + * Returns the active {@link ApplicationContext}. Be default this method obtains + * the context via {@link ContextLoader#getCurrentWebApplicationContext()}, which + * finds the ApplicationContext loaded via {@link ContextLoader} typically in a + * Servlet container environment. When not running in a Servlet container and + * not using {@link ContextLoader}, this method should be overridden. + * @return the {@link ApplicationContext} or {@code null} + */ + protected ApplicationContext getApplicationContext() { + return ContextLoader.getCurrentWebApplicationContext(); + } + + /** + * Returns the type being converted. By default the type is resolved using + * the generic arguments of the class. + */ + protected TypeDescriptor getType() { + return TypeDescriptor.valueOf(resolveTypeArguments()[0]); + } + + /** + * Returns the websocket message type. By default the type is resolved using + * the generic arguments of the class. + */ + protected TypeDescriptor getMessageType() { + return TypeDescriptor.valueOf(resolveTypeArguments()[1]); + } + + private Class[] resolveTypeArguments() { + return GenericTypeResolver.resolveTypeArguments(getClass(), + ConvertingEncoderDecoderSupport.class); + } + + /** + * @see javax.websocket.Encoder.Text#encode(Object) + * @see javax.websocket.Encoder.Binary#encode(Object) + */ + @SuppressWarnings("unchecked") + public M encode(T object) throws EncodeException { + try { + return (M) getConversionService().convert(object, getType(), getMessageType()); + } + catch (ConversionException ex) { + throw new EncodeException(object, "Unable to encode websocket message using ConversionService", ex); + } + } + + /** + * @see javax.websocket.Decoder.Text#willDecode(String) + * @see javax.websocket.Decoder.Binary#willDecode(ByteBuffer) + */ + public boolean willDecode(M bytes) { + return getConversionService().canConvert(getType(), getMessageType()); + } + + /** + * @see javax.websocket.Decoder.Text#decode(String) + * @see javax.websocket.Decoder.Binary#decode(ByteBuffer) + */ + @SuppressWarnings("unchecked") + public T decode(M message) throws DecodeException { + try { + return (T) getConversionService().convert(message, getMessageType(), getType()); + } + catch (ConversionException ex) { + if (message instanceof String) { + throw new DecodeException((String) message, "Unable to decode " + + "websocket message using ConversionService", ex); + } + if (message instanceof ByteBuffer) { + throw new DecodeException((ByteBuffer) message, "Unable to decode " + + "websocket message using ConversionService", ex); + } + throw ex; + } + } + + + /** + * A Binary {@link javax.websocket.Encoder.Binary javax.websocket.Encoder} that + * delegates to Spring's conversion service. See + * {@link ConvertingEncoderDecoderSupport} for details. + * + * @param The type that this Encoder can convert to. + */ + public static abstract class BinaryEncoder extends + ConvertingEncoderDecoderSupport implements Encoder.Binary { + } + + + /** + * A Binary {@link javax.websocket.Encoder.Binary javax.websocket.Encoder} that delegates + * to Spring's conversion service. See {@link ConvertingEncoderDecoderSupport} for + * details. + * + * @param The type that this Decoder can convert from. + */ + public static abstract class BinaryDecoder extends + ConvertingEncoderDecoderSupport implements Decoder.Binary { + } + + + /** + * A Text {@link javax.websocket.Encoder.Text javax.websocket.Encoder} that delegates + * to Spring's conversion service. See {@link ConvertingEncoderDecoderSupport} for + * details. + * + * @param The type that this Encoder can convert to. + */ + public static abstract class TextEncoder extends + ConvertingEncoderDecoderSupport implements Encoder.Text { + } + + + /** + * A Text {@link javax.websocket.Encoder.Text javax.websocket.Encoder} that delegates + * to Spring's conversion service. See {@link ConvertingEncoderDecoderSupport} for + * details. + * + * @param The type that this Decoder can convert from. + */ + public static abstract class TextDecoder extends + ConvertingEncoderDecoderSupport implements Decoder.Text { + } + +} diff --git a/spring-websocket/src/test/java/org/springframework/web/socket/ContextLoaderTestUtils.java b/spring-websocket/src/test/java/org/springframework/web/socket/ContextLoaderTestUtils.java new file mode 100644 index 00000000000..d0024dbbb14 --- /dev/null +++ b/spring-websocket/src/test/java/org/springframework/web/socket/ContextLoaderTestUtils.java @@ -0,0 +1,58 @@ +/* + * Copyright 2002-2013 the original author or authors. + * + * 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.springframework.web.socket; + +import java.lang.reflect.Field; +import java.util.Map; + +import org.springframework.web.context.ContextLoader; +import org.springframework.web.context.WebApplicationContext; + +/** + * General test utilities for manipulating the {@link ContextLoader}. + * + * @author Phillip Webb + */ +public class ContextLoaderTestUtils { + + private static Map currentContextPerThread = getCurrentContextPerThreadFromContextLoader(); + + public static void setCurrentWebApplicationContext(WebApplicationContext applicationContext) { + setCurrentWebApplicationContext(Thread.currentThread().getContextClassLoader(), applicationContext); + } + + public static void setCurrentWebApplicationContext(ClassLoader classLoader, WebApplicationContext applicationContext) { + if(applicationContext != null) { + currentContextPerThread.put(classLoader, applicationContext); + } else { + currentContextPerThread.remove(classLoader); + } + } + + @SuppressWarnings("unchecked") + private static Map getCurrentContextPerThreadFromContextLoader() { + try { + Field field = ContextLoader.class.getDeclaredField("currentContextPerThread"); + field.setAccessible(true); + return (Map) field.get(null); + } + catch (Exception ex) { + throw new IllegalStateException(ex); + } + } + +} diff --git a/spring-websocket/src/test/java/org/springframework/web/socket/adapter/ConvertingEncoderDecoderSupportTests.java b/spring-websocket/src/test/java/org/springframework/web/socket/adapter/ConvertingEncoderDecoderSupportTests.java new file mode 100644 index 00000000000..1ed49a81e79 --- /dev/null +++ b/spring-websocket/src/test/java/org/springframework/web/socket/adapter/ConvertingEncoderDecoderSupportTests.java @@ -0,0 +1,317 @@ +/* + * Copyright 2002-2013 the original author or authors. + * + * 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.springframework.web.socket.adapter; + +import java.nio.ByteBuffer; + +import javax.websocket.DecodeException; +import javax.websocket.Decoder; +import javax.websocket.EncodeException; +import javax.websocket.Encoder; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.ConverterNotFoundException; +import org.springframework.core.convert.converter.Converter; +import org.springframework.core.convert.support.ByteBufferConverter; +import org.springframework.core.convert.support.GenericConversionService; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; +import org.springframework.web.socket.ContextLoaderTestUtils; + +import static org.hamcrest.Matchers.*; +import static org.junit.Assert.*; + +/** + * Test for {@link ConvertingEncoderDecoderSupport}. + * + * @author Phillip Webb + */ +public class ConvertingEncoderDecoderSupportTests { + + private static final String CONVERTED_TEXT = "_test"; + + private static final ByteBuffer CONVERTED_BYTES = ByteBuffer.wrap("~test".getBytes()); + + + @Rule + public ExpectedException thown = ExpectedException.none(); + + private WebApplicationContext applicationContext; + + private MyType myType = new MyType("test"); + + + @Before + public void setup() { + setup(Config.class); + } + + @After + public void teardown() { + ContextLoaderTestUtils.setCurrentWebApplicationContext(null); + } + + private void setup(Class configurationClass) { + AnnotationConfigWebApplicationContext applicationContext = new AnnotationConfigWebApplicationContext(); + applicationContext.register(configurationClass); + applicationContext.refresh(); + this.applicationContext = applicationContext; + ContextLoaderTestUtils.setCurrentWebApplicationContext(this.applicationContext); + } + + @Test + public void encodeToText() throws Exception { + assertThat(new MyTextEncoder().encode(myType), equalTo(CONVERTED_TEXT)); + } + + @Test + public void encodeToTextCannotConvert() throws Exception { + setup(NoConvertersConfig.class); + thown.expect(EncodeException.class); + thown.expectCause(isA(ConverterNotFoundException.class)); + new MyTextEncoder().encode(myType); + } + + @Test + public void encodeToBinary() throws Exception { + assertThat(new MyBinaryEncoder().encode(myType).array(), + equalTo(CONVERTED_BYTES.array())); + } + + @Test + public void encodeToBinaryCannotConvert() throws Exception { + setup(NoConvertersConfig.class); + thown.expect(EncodeException.class); + thown.expectCause(isA(ConverterNotFoundException.class)); + new MyBinaryEncoder().encode(myType); + } + + @Test + public void decodeFromText() throws Exception { + Decoder.Text decoder = new MyTextDecoder(); + assertThat(decoder.willDecode(CONVERTED_TEXT), is(true)); + assertThat(decoder.decode(CONVERTED_TEXT), equalTo(myType)); + } + + @Test + public void decodeFromTextCannotConvert() throws Exception { + setup(NoConvertersConfig.class); + Decoder.Text decoder = new MyTextDecoder(); + assertThat(decoder.willDecode(CONVERTED_TEXT), is(false)); + thown.expect(DecodeException.class); + thown.expectCause(isA(ConverterNotFoundException.class)); + decoder.decode(CONVERTED_TEXT); + } + + @Test + public void decodeFromBinary() throws Exception { + Decoder.Binary decoder = new MyBinaryDecoder(); + assertThat(decoder.willDecode(CONVERTED_BYTES), is(true)); + assertThat(decoder.decode(CONVERTED_BYTES), equalTo(myType)); + } + + @Test + public void decodeFromBinaryCannotConvert() throws Exception { + setup(NoConvertersConfig.class); + Decoder.Binary decoder = new MyBinaryDecoder(); + assertThat(decoder.willDecode(CONVERTED_BYTES), is(false)); + thown.expect(DecodeException.class); + thown.expectCause(isA(ConverterNotFoundException.class)); + decoder.decode(CONVERTED_BYTES); + } + + @Test + public void encodeAndDecodeText() throws Exception { + MyTextEncoderDecoder encoderDecoder = new MyTextEncoderDecoder(); + String encoded = encoderDecoder.encode(myType); + assertThat(encoderDecoder.decode(encoded), equalTo(myType)); + } + + @Test + public void encodeAndDecodeBytes() throws Exception { + MyBinaryEncoderDecoder encoderDecoder = new MyBinaryEncoderDecoder(); + ByteBuffer encoded = encoderDecoder.encode(myType); + assertThat(encoderDecoder.decode(encoded), equalTo(myType)); + } + + @Test + public void autowiresIntoEncoder() throws Exception { + WithAutowire withAutowire = new WithAutowire(); + withAutowire.init(null); + assertThat(withAutowire.config, equalTo(applicationContext.getBean(Config.class))); + } + + @Test + public void cannotFindApplicationContext() throws Exception { + ContextLoaderTestUtils.setCurrentWebApplicationContext(null); + WithAutowire encoder = new WithAutowire(); + encoder.init(null); + thown.expect(IllegalStateException.class); + thown.expectMessage("Unable to locate the Spring ApplicationContext"); + encoder.encode(myType); + } + + @Test + public void cannotFindConversionService() throws Exception { + setup(NoConfig.class); + MyBinaryEncoder encoder = new MyBinaryEncoder(); + encoder.init(null); + thown.expect(IllegalStateException.class); + thown.expectMessage("Unable to find ConversionService"); + encoder.encode(myType); + } + + @Configuration + public static class Config { + + @Bean + public ConversionService webSocketConversionService() { + GenericConversionService conversionService = new GenericConversionService(); + conversionService.addConverter(new ByteBufferConverter(conversionService)); + conversionService.addConverter(new MyTypeToStringConverter()); + conversionService.addConverter(new MyTypeToBytesConverter()); + conversionService.addConverter(new StringToMyTypeConverter()); + conversionService.addConverter(new BytesToMyTypeConverter()); + return conversionService; + } + + } + + @Configuration + public static class NoConvertersConfig { + + @Bean + public ConversionService webSocketConversionService() { + return new GenericConversionService(); + } + + } + + + @Configuration + public static class NoConfig { + } + + + public static class MyType { + + private String value; + + public MyType(String value) { + this.value = value; + } + + @Override + public String toString() { + return this.value; + } + + @Override + public int hashCode() { + return value.hashCode(); + } + + @Override + public boolean equals(Object obj) { + if(obj instanceof MyType) { + return ((MyType)obj).value.equals(value); + } + return false; + } + } + + + private static class MyTypeToStringConverter implements Converter { + @Override + public String convert(MyType source) { + return "_" + source.toString(); + } + } + + + private static class MyTypeToBytesConverter implements Converter { + @Override + public byte[] convert(MyType source) { + return ("~" + source.toString()).getBytes(); + } + } + + + private static class StringToMyTypeConverter implements Converter { + @Override + public MyType convert(String source) { + return new MyType(source.substring(1)); + } + } + + + private static class BytesToMyTypeConverter implements Converter { + @Override + public MyType convert(byte[] source) { + return new MyType(new String(source).substring(1)); + } + } + + + public static class MyTextEncoder extends + ConvertingEncoderDecoderSupport.TextEncoder { + } + + + public static class MyBinaryEncoder extends + ConvertingEncoderDecoderSupport.BinaryEncoder { + } + + + public static class MyTextDecoder extends + ConvertingEncoderDecoderSupport.TextDecoder { + } + + + public static class MyBinaryDecoder extends + ConvertingEncoderDecoderSupport.BinaryDecoder { + } + + + public static class MyTextEncoderDecoder extends + ConvertingEncoderDecoderSupport implements Encoder.Text, + Decoder.Text { + } + + + public static class MyBinaryEncoderDecoder extends + ConvertingEncoderDecoderSupport implements Encoder.Binary, + Decoder.Binary { + } + + + public static class WithAutowire extends ConvertingEncoderDecoderSupport.TextDecoder { + + @Autowired + private Config config; + + } + +}