diff --git a/README-WEBSOCKET.md b/README-WEBSOCKET.md new file mode 100644 index 0000000000..27c1e641b6 --- /dev/null +++ b/README-WEBSOCKET.md @@ -0,0 +1,88 @@ + +## Maven Snapshots + +Maven snapshots of this branch are available through the Spring snapshot repository: + + + spring-snapshots + http://repo.springsource.org/snapshot + true + false + + +Use version `4.0.0.WEBSOCKET-SNAPSHOT`, for example: + + + org.springframework + spring-context + 4.0.0.WEBSOCKET-SNAPSHOT + + + org.springframework + spring-web + 4.0.0.WEBSOCKET-SNAPSHOT + + + org.springframework + spring-websocket + 4.0.0.WEBSOCKET-SNAPSHOT + + + + +### Tomcat + +Tomcat provides early JSR-356 support. You'll need to build the latest source, which is relatively easy to do. + +Check out Tomcat trunk: + + mkdir tomcat + cd tomcat + svn co http://svn.apache.org/repos/asf/tomcat/trunk/ + cd trunk + +Create `build.properties` in the trunk directory with similar content: + + # ----- Default Base Path for Dependent Packages ----- + # Replace this path with the path where dependencies binaries should be downloaded + base.path=~/dev/sources/apache/tomcat/download + +Run the ant build: + + ant clean + ant + +A usable Tomcat installation can be found in `output/build` + +### Jetty 9 + +Download and use the latest Jetty (currently 9.0.2.v20130417). It does not support JSR-356 yet but that's not an issue, since we're using the Jetty 9 native WebSocket API. + +If using Java-based Servlet configuration instead of web.xml, add the following options to Jetty's start.ini: + + OPTIONS=plus + etc/jetty-plus.xml + OPTIONS=annotations + etc/jetty-annotations.xml + +### Glassfish + +Glassfish also provides JSR-356 support based on Tyrus (the reference implementation). + +Download a [Glassfish 4 build](http://dlc.sun.com.edgesuite.net/glassfish/4.0/) (e.g. glassfish-4.0-b84.zip from the promoted builds) + +Unzip the downloaded file. + +Start the server: + + cd /glassfish4 + bin/asadmin start-domain + +Deploy a WAR file. Here is [a sample script](https://github.com/rstoyanchev/spring-websocket-test/blob/master/redeploy-glassfish.sh). + +Watch the logs: + + cd /glassfish4 + less `glassfish/domains/domain1/logs/server.log` + + diff --git a/build.gradle b/build.gradle index 4dd89a12fa..ea0c656522 100644 --- a/build.gradle +++ b/build.gradle @@ -499,6 +499,42 @@ project("spring-orm-hibernate4") { } } +project("spring-websocket") { + description = "Spring WebSocket support" + dependencies { + compile(project(":spring-core")) + compile(project(":spring-context")) + compile(project(":spring-web")) + + optional("org.apache.tomcat:tomcat-servlet-api:8.0-SNAPSHOT") // TODO: replace with "javax.servlet:javax.servlet-api" + optional("org.apache.tomcat:tomcat-websocket-api:8.0-SNAPSHOT") // TODO: replace with "javax.websocket:javax.websocket-api" + + optional("org.apache.tomcat:tomcat-websocket:8.0-SNAPSHOT") { + exclude group: "org.apache.tomcat", module: "tomcat-websocket-api" + exclude group: "org.apache.tomcat", module: "tomcat-servlet-api" + } + + optional("org.glassfish.tyrus:tyrus-websocket-core:1.0-SNAPSHOT") + optional("org.glassfish.tyrus:tyrus-container-servlet:1.0-SNAPSHOT") + + optional("org.eclipse.jetty:jetty-webapp:9.0.1.v20130408") { + exclude group: "org.eclipse.jetty.orbit", module: "javax.servlet" + } + optional("org.eclipse.jetty.websocket:websocket-server:9.0.1.v20130408") + optional("org.eclipse.jetty.websocket:websocket-client:9.0.1.v20130408") + + optional("com.fasterxml.jackson.core:jackson-databind:2.0.1") // required for SockJS support currently + + } + + repositories { + maven { url "http://repo.springsource.org/libs-release" } + maven { url "https://maven.java.net/content/groups/public/" } // javax.websocket-* + maven { url "https://repository.apache.org/content/repositories/snapshots" } // tomcat-websocket snapshots + maven { url "https://maven.java.net/content/repositories/snapshots" } // tyrus/glassfish snapshots + } +} + project("spring-webmvc") { description = "Spring Web MVC" diff --git a/gradle.properties b/gradle.properties index 2db7ae75f2..712344802e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -version=4.0.0.BUILD-SNAPSHOT +version=4.0.0.WEBSOCKET-SNAPSHOT diff --git a/settings.gradle b/settings.gradle index 71b5e8408d..3511d2a86f 100644 --- a/settings.gradle +++ b/settings.gradle @@ -21,6 +21,7 @@ include "spring-web" include "spring-webmvc" include "spring-webmvc-portlet" include "spring-webmvc-tiles3" +include "spring-websocket" // Exposes gradle buildSrc for IDE support include "buildSrc" diff --git a/spring-test/src/main/java/org/springframework/mock/http/MockHttpInputMessage.java b/spring-test/src/main/java/org/springframework/mock/http/MockHttpInputMessage.java index 5de14e9954..d73a1ea0ef 100644 --- a/spring-test/src/main/java/org/springframework/mock/http/MockHttpInputMessage.java +++ b/spring-test/src/main/java/org/springframework/mock/http/MockHttpInputMessage.java @@ -19,6 +19,7 @@ import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; +import org.springframework.http.Cookies; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpInputMessage; import org.springframework.util.Assert; @@ -35,6 +36,8 @@ public class MockHttpInputMessage implements HttpInputMessage { private final InputStream body; + private final Cookies cookies = new Cookies(); + public MockHttpInputMessage(byte[] contents) { this.body = (contents != null) ? new ByteArrayInputStream(contents) : null; @@ -53,4 +56,8 @@ public class MockHttpInputMessage implements HttpInputMessage { return this.body; } + @Override + public Cookies getCookies() { + return this.cookies ; + } } diff --git a/spring-test/src/main/java/org/springframework/mock/http/MockHttpOutputMessage.java b/spring-test/src/main/java/org/springframework/mock/http/MockHttpOutputMessage.java index 43fa1b3e7e..8cda7862a5 100644 --- a/spring-test/src/main/java/org/springframework/mock/http/MockHttpOutputMessage.java +++ b/spring-test/src/main/java/org/springframework/mock/http/MockHttpOutputMessage.java @@ -21,6 +21,7 @@ import java.io.OutputStream; import java.io.UnsupportedEncodingException; import java.nio.charset.Charset; +import org.springframework.http.Cookies; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpOutputMessage; @@ -38,6 +39,7 @@ public class MockHttpOutputMessage implements HttpOutputMessage { private final ByteArrayOutputStream body = new ByteArrayOutputStream(); + private final Cookies cookies = new Cookies(); /** * Return the headers. @@ -83,4 +85,9 @@ public class MockHttpOutputMessage implements HttpOutputMessage { } } + @Override + public Cookies getCookies() { + return this.cookies; + } + } diff --git a/spring-web/src/main/java/org/springframework/http/Cookie.java b/spring-web/src/main/java/org/springframework/http/Cookie.java new file mode 100644 index 0000000000..a60c73c87f --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/Cookie.java @@ -0,0 +1,25 @@ +/* + * 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.http; + + +public interface Cookie { + + String getName(); + + String getValue(); + +} diff --git a/spring-web/src/main/java/org/springframework/http/Cookies.java b/spring-web/src/main/java/org/springframework/http/Cookies.java new file mode 100644 index 0000000000..7dc13536c9 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/Cookies.java @@ -0,0 +1,59 @@ +/* + * 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.http; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + + +public class Cookies { + + private final List cookies; + + + public Cookies() { + this.cookies = new ArrayList(); + } + + private Cookies(Cookies cookies) { + this.cookies = Collections.unmodifiableList(cookies.getCookies()); + } + + public static Cookies readOnlyCookies(Cookies cookies) { + return new Cookies(cookies); + } + + public List getCookies() { + return this.cookies; + } + + public Cookie getCookie(String name) { + for (Cookie c : this.cookies) { + if (c.getName().equals(name)) { + return c; + } + } + return null; + } + + public Cookie addCookie(String name, String value) { + DefaultCookie cookie = new DefaultCookie(name, value); + this.cookies.add(cookie); + return cookie; + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/DefaultCookie.java b/spring-web/src/main/java/org/springframework/http/DefaultCookie.java new file mode 100644 index 0000000000..82a09ba26e --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/DefaultCookie.java @@ -0,0 +1,40 @@ +/* + * 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.http; + +import org.springframework.util.Assert; + +public class DefaultCookie implements Cookie { + + private final String name; + + private final String value; + + DefaultCookie(String name, String value) { + Assert.hasText(name, "cookie name must not be empty"); + this.name = name; + this.value = value; + } + + public String getName() { + return name; + } + + public String getValue() { + return value; + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/HttpHeaders.java b/spring-web/src/main/java/org/springframework/http/HttpHeaders.java index 5d480d5e4b..e972709572 100644 --- a/spring-web/src/main/java/org/springframework/http/HttpHeaders.java +++ b/spring-web/src/main/java/org/springframework/http/HttpHeaders.java @@ -17,14 +17,10 @@ package org.springframework.http; import java.io.Serializable; - import java.net.URI; - import java.nio.charset.Charset; - import java.text.ParseException; import java.text.SimpleDateFormat; - import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -40,6 +36,7 @@ import java.util.Set; import java.util.TimeZone; import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; import org.springframework.util.LinkedCaseInsensitiveMap; import org.springframework.util.MultiValueMap; import org.springframework.util.StringUtils; @@ -71,6 +68,8 @@ public class HttpHeaders implements MultiValueMap, Serializable private static final String CACHE_CONTROL = "Cache-Control"; + private static final String CONNECTION = "Connection"; + private static final String CONTENT_DISPOSITION = "Content-Disposition"; private static final String CONTENT_LENGTH = "Content-Length"; @@ -91,8 +90,22 @@ public class HttpHeaders implements MultiValueMap, Serializable private static final String LOCATION = "Location"; + private static final String ORIGIN = "Origin"; + + private static final String SEC_WEBSOCKET_ACCEPT = "Sec-WebSocket-Accept"; + + private static final String SEC_WEBSOCKET_EXTENSIONS = "Sec-WebSocket-Extensions"; + + private static final String SEC_WEBSOCKET_KEY = "Sec-WebSocket-Key"; + + private static final String SEC_WEBSOCKET_PROTOCOL = "Sec-WebSocket-Protocol"; + + private static final String SEC_WEBSOCKET_VERSION = "Sec-WebSocket-Version"; + private static final String PRAGMA = "Pragma"; + private static final String UPGARDE = "Upgrade"; + private static final String[] DATE_FORMATS = new String[] { "EEE, dd MMM yyyy HH:mm:ss zzz", @@ -251,6 +264,30 @@ public class HttpHeaders implements MultiValueMap, Serializable return getFirst(CACHE_CONTROL); } + /** + * Sets the (new) value of the {@code Connection} header. + * @param connection the value of the header + */ + public void setConnection(String connection) { + set(CONNECTION, connection); + } + + /** + * Sets the (new) value of the {@code Connection} header. + * @param connection the value of the header + */ + public void setConnection(List connection) { + set(CONNECTION, toCommaDelimitedString(connection)); + } + + /** + * Returns the value of the {@code Connection} header. + * @return the value of the header + */ + public List getConnection() { + return getFirstValueAsList(CONNECTION); + } + /** * Sets the (new) value of the {@code Content-Disposition} header for {@code form-data}. * @param name the control name @@ -393,15 +430,19 @@ public class HttpHeaders implements MultiValueMap, Serializable * @param ifNoneMatchList the new value of the header */ public void setIfNoneMatch(List ifNoneMatchList) { + set(IF_NONE_MATCH, toCommaDelimitedString(ifNoneMatchList)); + } + + private String toCommaDelimitedString(List list) { StringBuilder builder = new StringBuilder(); - for (Iterator iterator = ifNoneMatchList.iterator(); iterator.hasNext();) { + for (Iterator iterator = list.iterator(); iterator.hasNext();) { String ifNoneMatch = iterator.next(); builder.append(ifNoneMatch); if (iterator.hasNext()) { builder.append(", "); } } - set(IF_NONE_MATCH, builder.toString()); + return builder.toString(); } /** @@ -409,9 +450,13 @@ public class HttpHeaders implements MultiValueMap, Serializable * @return the header value */ public List getIfNoneMatch() { + return getFirstValueAsList(IF_NONE_MATCH); + } + + private List getFirstValueAsList(String header) { List result = new ArrayList(); - String value = getFirst(IF_NONE_MATCH); + String value = getFirst(header); if (value != null) { String[] tokens = value.split(",\\s*"); for (String token : tokens) { @@ -457,6 +502,130 @@ public class HttpHeaders implements MultiValueMap, Serializable return (value != null ? URI.create(value) : null); } + /** + * Sets the (new) value of the {@code Origin} header. + * @param origin the value of the header + */ + public void setOrigin(String origin) { + set(ORIGIN, origin); + } + + /** + * Returns the value of the {@code Origin} header. + * @return the value of the header + */ + public String getOrigin() { + return getFirst(ORIGIN); + } + + /** + * Sets the (new) value of the {@code Sec-WebSocket-Accept} header. + * @param secWebSocketAccept the value of the header + */ + public void setSecWebSocketAccept(String secWebSocketAccept) { + set(SEC_WEBSOCKET_ACCEPT, secWebSocketAccept); + } + + /** + * Returns the value of the {@code Sec-WebSocket-Accept} header. + * @return the value of the header + */ + public String getSecWebSocketAccept() { + return getFirst(SEC_WEBSOCKET_ACCEPT); + } + + /** + * Returns the value of the {@code Sec-WebSocket-Extensions} header. + * @return the value of the header + */ + public List getSecWebSocketExtensions() { + List values = get(SEC_WEBSOCKET_EXTENSIONS); + if (CollectionUtils.isEmpty(values)) { + return Collections.emptyList(); + } + else if (values.size() == 1) { + return getFirstValueAsList(SEC_WEBSOCKET_EXTENSIONS); + } + else { + return values; + } + } + + /** + * Sets the (new) value of the {@code Sec-WebSocket-Extensions} header. + * @param secWebSocketExtensions the value of the header + */ + public void setSecWebSocketExtensions(List secWebSocketExtensions) { + set(SEC_WEBSOCKET_EXTENSIONS, toCommaDelimitedString(secWebSocketExtensions)); + } + + /** + * Sets the (new) value of the {@code Sec-WebSocket-Key} header. + * @param secWebSocketKey the value of the header + */ + public void setSecWebSocketKey(String secWebSocketKey) { + set(SEC_WEBSOCKET_KEY, secWebSocketKey); + } + + /** + * Returns the value of the {@code Sec-WebSocket-Key} header. + * @return the value of the header + */ + public String getSecWebSocketKey() { + return getFirst(SEC_WEBSOCKET_KEY); + } + + /** + * Sets the (new) value of the {@code Sec-WebSocket-Protocol} header. + * @param secWebSocketProtocol the value of the header + */ + public void setSecWebSocketProtocol(String secWebSocketProtocol) { + if (secWebSocketProtocol != null) { + set(SEC_WEBSOCKET_PROTOCOL, secWebSocketProtocol); + } + } + + /** + * Sets the (new) value of the {@code Sec-WebSocket-Protocol} header. + * @param secWebSocketProtocols the value of the header + */ + public void setSecWebSocketProtocol(List secWebSocketProtocols) { + set(SEC_WEBSOCKET_PROTOCOL, toCommaDelimitedString(secWebSocketProtocols)); + } + + /** + * Returns the value of the {@code Sec-WebSocket-Key} header. + * @return the value of the header + */ + public List getSecWebSocketProtocol() { + List values = get(SEC_WEBSOCKET_PROTOCOL); + if (CollectionUtils.isEmpty(values)) { + return Collections.emptyList(); + } + else if (values.size() == 1) { + return getFirstValueAsList(SEC_WEBSOCKET_PROTOCOL); + } + else { + return values; + } + } + + /** + * Sets the (new) value of the {@code Sec-WebSocket-Version} header. + * @param secWebSocketKey the value of the header + */ + public void setSecWebSocketVersion(String secWebSocketVersion) { + set(SEC_WEBSOCKET_VERSION, secWebSocketVersion); + } + + /** + * Returns the value of the {@code Sec-WebSocket-Version} header. + * @return the value of the header + */ + public String getSecWebSocketVersion() { + return getFirst(SEC_WEBSOCKET_VERSION); + } + /** * Sets the (new) value of the {@code Pragma} header. * @param pragma the value of the header @@ -473,6 +642,22 @@ public class HttpHeaders implements MultiValueMap, Serializable return getFirst(PRAGMA); } + /** + * Sets the (new) value of the {@code Upgrade} header. + * @param upgrade the value of the header + */ + public void setUpgrade(String upgrade) { + set(UPGARDE, upgrade); + } + + /** + * Returns the value of the {@code Upgrade} header. + * @return the value of the header + */ + public String getUpgrade() { + return getFirst(UPGARDE); + } + // Utility methods private long getFirstDate(String headerName) { diff --git a/spring-web/src/main/java/org/springframework/http/HttpMessage.java b/spring-web/src/main/java/org/springframework/http/HttpMessage.java index 80f7ca292d..05824e67ab 100644 --- a/spring-web/src/main/java/org/springframework/http/HttpMessage.java +++ b/spring-web/src/main/java/org/springframework/http/HttpMessage.java @@ -31,4 +31,9 @@ public interface HttpMessage { */ HttpHeaders getHeaders(); + /** + * TODO .. + */ + Cookies getCookies(); + } diff --git a/spring-web/src/main/java/org/springframework/http/client/AbstractClientHttpRequest.java b/spring-web/src/main/java/org/springframework/http/client/AbstractClientHttpRequest.java index 47422a0065..9084967078 100644 --- a/spring-web/src/main/java/org/springframework/http/client/AbstractClientHttpRequest.java +++ b/spring-web/src/main/java/org/springframework/http/client/AbstractClientHttpRequest.java @@ -19,6 +19,7 @@ package org.springframework.http.client; import java.io.IOException; import java.io.OutputStream; +import org.springframework.http.Cookies; import org.springframework.http.HttpHeaders; import org.springframework.util.Assert; @@ -44,6 +45,11 @@ public abstract class AbstractClientHttpRequest implements ClientHttpRequest { return getBodyInternal(this.headers); } + public Cookies getCookies() { + // TODO + throw new UnsupportedOperationException(); + } + public final ClientHttpResponse execute() throws IOException { checkExecuted(); ClientHttpResponse result = executeInternal(this.headers); diff --git a/spring-web/src/main/java/org/springframework/http/client/AbstractClientHttpResponse.java b/spring-web/src/main/java/org/springframework/http/client/AbstractClientHttpResponse.java index cd6166575b..33c123cff9 100644 --- a/spring-web/src/main/java/org/springframework/http/client/AbstractClientHttpResponse.java +++ b/spring-web/src/main/java/org/springframework/http/client/AbstractClientHttpResponse.java @@ -18,6 +18,7 @@ package org.springframework.http.client; import java.io.IOException; +import org.springframework.http.Cookies; import org.springframework.http.HttpStatus; /** @@ -32,4 +33,9 @@ public abstract class AbstractClientHttpResponse implements ClientHttpResponse { return HttpStatus.valueOf(getRawStatusCode()); } + public Cookies getCookies() { + // TODO + throw new UnsupportedOperationException(); + } + } diff --git a/spring-web/src/main/java/org/springframework/http/client/BufferingClientHttpRequestWrapper.java b/spring-web/src/main/java/org/springframework/http/client/BufferingClientHttpRequestWrapper.java index bb87844420..794ca6ac2a 100644 --- a/spring-web/src/main/java/org/springframework/http/client/BufferingClientHttpRequestWrapper.java +++ b/spring-web/src/main/java/org/springframework/http/client/BufferingClientHttpRequestWrapper.java @@ -17,9 +17,9 @@ package org.springframework.http.client; import java.io.IOException; -import java.io.OutputStream; import java.net.URI; +import org.springframework.http.Cookies; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.util.Assert; @@ -58,4 +58,9 @@ final class BufferingClientHttpRequestWrapper extends AbstractBufferingClientHtt return new BufferingClientHttpResponseWrapper(response); } + @Override + public Cookies getCookies() { + return this.request.getCookies(); + } + } diff --git a/spring-web/src/main/java/org/springframework/http/client/BufferingClientHttpResponseWrapper.java b/spring-web/src/main/java/org/springframework/http/client/BufferingClientHttpResponseWrapper.java index f075b202bd..382b3fa20a 100644 --- a/spring-web/src/main/java/org/springframework/http/client/BufferingClientHttpResponseWrapper.java +++ b/spring-web/src/main/java/org/springframework/http/client/BufferingClientHttpResponseWrapper.java @@ -20,9 +20,9 @@ import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; +import org.springframework.http.Cookies; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; -import org.springframework.util.FileCopyUtils; import org.springframework.util.StreamUtils; /** @@ -67,6 +67,10 @@ final class BufferingClientHttpResponseWrapper implements ClientHttpResponse { return new ByteArrayInputStream(this.body); } + public Cookies getCookies() { + return this.response.getCookies(); + } + public void close() { this.response.close(); } diff --git a/spring-web/src/main/java/org/springframework/http/client/support/HttpRequestWrapper.java b/spring-web/src/main/java/org/springframework/http/client/support/HttpRequestWrapper.java index 4aecd01dcd..c9c8ef9955 100644 --- a/spring-web/src/main/java/org/springframework/http/client/support/HttpRequestWrapper.java +++ b/spring-web/src/main/java/org/springframework/http/client/support/HttpRequestWrapper.java @@ -18,6 +18,7 @@ package org.springframework.http.client.support; import java.net.URI; +import org.springframework.http.Cookies; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpRequest; @@ -73,4 +74,11 @@ public class HttpRequestWrapper implements HttpRequest { return this.request.getHeaders(); } + /** + * Returns the cookies of the wrapped request. + */ + public Cookies getCookies() { + return this.request.getCookies(); + } + } diff --git a/spring-web/src/main/java/org/springframework/http/converter/FormHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/FormHttpMessageConverter.java index 516eb92ab2..5ddcfead7b 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/FormHttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/FormHttpMessageConverter.java @@ -30,6 +30,7 @@ import java.util.Map; import java.util.Random; import org.springframework.core.io.Resource; +import org.springframework.http.Cookies; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpInputMessage; @@ -383,6 +384,11 @@ public class FormHttpMessageConverter implements HttpMessageConverter> entry : this.headers.entrySet()) { diff --git a/spring-web/src/main/java/org/springframework/http/server/AsyncServerHttpRequest.java b/spring-web/src/main/java/org/springframework/http/server/AsyncServerHttpRequest.java new file mode 100644 index 0000000000..bbf973d5d8 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/server/AsyncServerHttpRequest.java @@ -0,0 +1,35 @@ +/* + * 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.http.server; + + + +/** + * TODO.. + */ +public interface AsyncServerHttpRequest extends ServerHttpRequest { + + void setTimeout(long timeout); + + void startAsync(); + + boolean isAsyncStarted(); + + void completeAsync(); + + boolean isAsyncCompleted(); + +} diff --git a/spring-web/src/main/java/org/springframework/http/server/AsyncServletServerHttpRequest.java b/spring-web/src/main/java/org/springframework/http/server/AsyncServletServerHttpRequest.java new file mode 100644 index 0000000000..996a7d3fdc --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/server/AsyncServletServerHttpRequest.java @@ -0,0 +1,149 @@ +/* + * 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.http.server; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +import javax.servlet.AsyncContext; +import javax.servlet.AsyncEvent; +import javax.servlet.AsyncListener; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.util.Assert; + + +public class AsyncServletServerHttpRequest extends ServletServerHttpRequest + implements AsyncServerHttpRequest, AsyncListener { + + private Long timeout; + + private AsyncContext asyncContext; + + private AtomicBoolean asyncCompleted = new AtomicBoolean(false); + + private final List timeoutHandlers = new ArrayList(); + + private final List completionHandlers = new ArrayList(); + + private final HttpServletResponse servletResponse; + + + /** + * Create a new instance for the given request/response pair. + */ + public AsyncServletServerHttpRequest(HttpServletRequest request, HttpServletResponse response) { + super(request); + this.servletResponse = response; + } + + /** + * Timeout period begins after the container thread has exited. + */ + public void setTimeout(long timeout) { + Assert.state(!isAsyncStarted(), "Cannot change the timeout with concurrent handling in progress"); + this.timeout = timeout; + } + + public void addTimeoutHandler(Runnable timeoutHandler) { + this.timeoutHandlers.add(timeoutHandler); + } + + public void addCompletionHandler(Runnable runnable) { + this.completionHandlers.add(runnable); + } + + public boolean isAsyncStarted() { + return ((this.asyncContext != null) && getServletRequest().isAsyncStarted()); + } + + /** + * Whether async request processing has completed. + *

It is important to avoid use of request and response objects after async + * processing has completed. Servlet containers often re-use them. + */ + public boolean isAsyncCompleted() { + return this.asyncCompleted.get(); + } + + public void startAsync() { + Assert.state(getServletRequest().isAsyncSupported(), + "Async support must be enabled on a servlet and for all filters involved " + + "in async request processing. This is done in Java code using the Servlet API " + + "or by adding \"true\" to servlet and " + + "filter declarations in web.xml."); + Assert.state(!isAsyncCompleted(), "Async processing has already completed"); + if (isAsyncStarted()) { + return; + } + this.asyncContext = getServletRequest().startAsync(getServletRequest(), this.servletResponse); + this.asyncContext.addListener(this); + if (this.timeout != null) { + this.asyncContext.setTimeout(this.timeout); + } + } + + public void completeAsync() { + Assert.notNull(this.asyncContext, "Cannot dispatch without an AsyncContext"); + if (isAsyncStarted() && !isAsyncCompleted()) { + this.asyncContext.complete(); + } + } + + + // --------------------------------------------------------------------- + // Implementation of AsyncListener methods + // --------------------------------------------------------------------- + + @Override + public void onStartAsync(AsyncEvent event) throws IOException { + } + + @Override + public void onError(AsyncEvent event) throws IOException { + } + + @Override + public void onTimeout(AsyncEvent event) throws IOException { + try { + for (Runnable handler : this.timeoutHandlers) { + handler.run(); + } + } + catch (Throwable t) { + // ignore + } + } + + @Override + public void onComplete(AsyncEvent event) throws IOException { + try { + for (Runnable handler : this.completionHandlers) { + handler.run(); + } + } + catch (Throwable t) { + // ignore + } + this.asyncContext = null; + this.asyncCompleted.set(true); + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/server/ServerHttpRequest.java b/spring-web/src/main/java/org/springframework/http/server/ServerHttpRequest.java index ce8fcd3ad2..c765fd3492 100644 --- a/spring-web/src/main/java/org/springframework/http/server/ServerHttpRequest.java +++ b/spring-web/src/main/java/org/springframework/http/server/ServerHttpRequest.java @@ -16,8 +16,11 @@ package org.springframework.http.server; +import java.security.Principal; + import org.springframework.http.HttpInputMessage; import org.springframework.http.HttpRequest; +import org.springframework.util.MultiValueMap; /** * Represents a server-side HTTP request. @@ -27,4 +30,26 @@ import org.springframework.http.HttpRequest; */ public interface ServerHttpRequest extends HttpRequest, HttpInputMessage { + /** + * Returns the map of query parameters. Empty if no query has been set. + */ + MultiValueMap getQueryParams(); + + /** + * Return a {@link java.security.Principal} instance containing the name of the + * authenticated user. If the user has not been authenticated, the method returns + * null. + */ + Principal getPrincipal(); + + /** + * Return the host name of the endpoint on the other end. + */ + String getRemoteHostName(); + + /** + * Return the IP address of the endpoint on the other end. + */ + String getRemoteAddress(); + } diff --git a/spring-web/src/main/java/org/springframework/http/server/ServerHttpResponse.java b/spring-web/src/main/java/org/springframework/http/server/ServerHttpResponse.java index 8ce306271f..02541fab92 100644 --- a/spring-web/src/main/java/org/springframework/http/server/ServerHttpResponse.java +++ b/spring-web/src/main/java/org/springframework/http/server/ServerHttpResponse.java @@ -17,6 +17,7 @@ package org.springframework.http.server; import java.io.Closeable; +import java.io.IOException; import org.springframework.http.HttpOutputMessage; import org.springframework.http.HttpStatus; @@ -35,6 +36,11 @@ public interface ServerHttpResponse extends HttpOutputMessage, Closeable { */ void setStatusCode(HttpStatus status); + /** + * TODO + */ + void flush() throws IOException; + /** * Close this response, freeing any resources created. */ diff --git a/spring-web/src/main/java/org/springframework/http/server/ServletServerHttpRequest.java b/spring-web/src/main/java/org/springframework/http/server/ServletServerHttpRequest.java index 5dd0dbd424..2236df64cd 100644 --- a/spring-web/src/main/java/org/springframework/http/server/ServletServerHttpRequest.java +++ b/spring-web/src/main/java/org/springframework/http/server/ServletServerHttpRequest.java @@ -26,6 +26,7 @@ import java.net.URI; import java.net.URISyntaxException; import java.net.URLEncoder; import java.nio.charset.Charset; +import java.security.Principal; import java.util.Arrays; import java.util.Enumeration; import java.util.HashMap; @@ -33,12 +34,16 @@ import java.util.Iterator; import java.util.List; import java.util.Map; +import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; +import org.springframework.http.Cookies; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.util.Assert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; /** * {@link ServerHttpRequest} implementation that is based on a {@link HttpServletRequest}. @@ -58,6 +63,10 @@ public class ServletServerHttpRequest implements ServerHttpRequest { private HttpHeaders headers; + private Cookies cookies; + + private MultiValueMap queryParams; + /** * Construct a new instance of the ServletServerHttpRequest based on the given {@link HttpServletRequest}. @@ -123,6 +132,45 @@ public class ServletServerHttpRequest implements ServerHttpRequest { return this.headers; } + @Override + public Principal getPrincipal() { + return this.servletRequest.getUserPrincipal(); + } + + @Override + public String getRemoteHostName() { + return this.servletRequest.getRemoteHost(); + } + + @Override + public String getRemoteAddress() { + return this.servletRequest.getRemoteAddr(); + } + + public Cookies getCookies() { + if (this.cookies == null) { + this.cookies = new Cookies(); + if (this.servletRequest.getCookies() != null) { + for (Cookie cookie : this.servletRequest.getCookies()) { + this.cookies.addCookie(cookie.getName(), cookie.getValue()); + } + } + } + return this.cookies; + } + + public MultiValueMap getQueryParams() { + if (this.queryParams == null) { + this.queryParams = new LinkedMultiValueMap(this.servletRequest.getParameterMap().size()); + for (String name : this.servletRequest.getParameterMap().keySet()) { + for (String value : this.servletRequest.getParameterValues(name)) { + this.queryParams.add(name, value); + } + } + } + return this.queryParams; + } + public InputStream getBody() throws IOException { if (isFormPost(this.servletRequest)) { return getBodyFromServletRequestParameters(this.servletRequest); diff --git a/spring-web/src/main/java/org/springframework/http/server/ServletServerHttpResponse.java b/spring-web/src/main/java/org/springframework/http/server/ServletServerHttpResponse.java index 985085e51e..3a2ba0445d 100644 --- a/spring-web/src/main/java/org/springframework/http/server/ServletServerHttpResponse.java +++ b/spring-web/src/main/java/org/springframework/http/server/ServletServerHttpResponse.java @@ -22,6 +22,8 @@ import java.util.List; import java.util.Map; import javax.servlet.http.HttpServletResponse; +import org.springframework.http.Cookie; +import org.springframework.http.Cookies; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.util.Assert; @@ -40,6 +42,8 @@ public class ServletServerHttpResponse implements ServerHttpResponse { private boolean headersWritten = false; + private final Cookies cookies = new Cookies(); + /** * Construct a new instance of the ServletServerHttpResponse based on the given {@link HttpServletResponse}. @@ -66,12 +70,25 @@ public class ServletServerHttpResponse implements ServerHttpResponse { return (this.headersWritten ? HttpHeaders.readOnlyHttpHeaders(this.headers) : this.headers); } + public Cookies getCookies() { + return (this.headersWritten ? Cookies.readOnlyCookies(this.cookies) : this.cookies); + } + public OutputStream getBody() throws IOException { + writeCookies(); writeHeaders(); return this.servletResponse.getOutputStream(); } + @Override + public void flush() throws IOException { + writeCookies(); + writeHeaders(); + this.servletResponse.flushBuffer(); + } + public void close() { + writeCookies(); writeHeaders(); } @@ -95,4 +112,13 @@ public class ServletServerHttpResponse implements ServerHttpResponse { } } + private void writeCookies() { + if (!this.headersWritten) { + for (Cookie source : this.cookies.getCookies()) { + javax.servlet.http.Cookie target = new javax.servlet.http.Cookie(source.getName(), source.getValue()); + target.setPath("/"); + this.servletResponse.addCookie(target); + } + } + } } diff --git a/spring-web/src/test/java/org/springframework/http/MockHttpInputMessage.java b/spring-web/src/test/java/org/springframework/http/MockHttpInputMessage.java index 18412ce33c..0ca8a29aca 100644 --- a/spring-web/src/test/java/org/springframework/http/MockHttpInputMessage.java +++ b/spring-web/src/test/java/org/springframework/http/MockHttpInputMessage.java @@ -31,6 +31,9 @@ public class MockHttpInputMessage implements HttpInputMessage { private final InputStream body; + private final Cookies cookies = new Cookies(); + + public MockHttpInputMessage(byte[] contents) { Assert.notNull(contents, "'contents' must not be null"); this.body = new ByteArrayInputStream(contents); @@ -50,4 +53,9 @@ public class MockHttpInputMessage implements HttpInputMessage { public InputStream getBody() throws IOException { return body; } + + @Override + public Cookies getCookies() { + return this.cookies ; + } } diff --git a/spring-web/src/test/java/org/springframework/http/MockHttpOutputMessage.java b/spring-web/src/test/java/org/springframework/http/MockHttpOutputMessage.java index cb08fa91a1..3287a7d93f 100644 --- a/spring-web/src/test/java/org/springframework/http/MockHttpOutputMessage.java +++ b/spring-web/src/test/java/org/springframework/http/MockHttpOutputMessage.java @@ -32,6 +32,9 @@ public class MockHttpOutputMessage implements HttpOutputMessage { private final ByteArrayOutputStream body = spy(new ByteArrayOutputStream()); + private final Cookies cookies = new Cookies(); + + @Override public HttpHeaders getHeaders() { return headers; @@ -50,4 +53,9 @@ public class MockHttpOutputMessage implements HttpOutputMessage { byte[] bytes = getBodyAsBytes(); return new String(bytes, charset); } + + @Override + public Cookies getCookies() { + return this.cookies; + } } diff --git a/spring-web/src/test/java/org/springframework/http/client/InterceptingClientHttpRequestFactoryTests.java b/spring-web/src/test/java/org/springframework/http/client/InterceptingClientHttpRequestFactoryTests.java index 2df6331b8e..0358bf42bf 100644 --- a/spring-web/src/test/java/org/springframework/http/client/InterceptingClientHttpRequestFactoryTests.java +++ b/spring-web/src/test/java/org/springframework/http/client/InterceptingClientHttpRequestFactoryTests.java @@ -29,6 +29,7 @@ import java.util.List; import org.junit.Before; import org.junit.Test; +import org.springframework.http.Cookies; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpRequest; @@ -253,6 +254,8 @@ public class InterceptingClientHttpRequestFactoryTests { private boolean executed = false; + private Cookies cookies = new Cookies(); + private RequestMock() { } @@ -289,6 +292,11 @@ public class InterceptingClientHttpRequestFactoryTests { executed = true; return responseMock; } + + @Override + public Cookies getCookies() { + return this.cookies ; + } } private static class ResponseMock implements ClientHttpResponse { @@ -299,6 +307,8 @@ public class InterceptingClientHttpRequestFactoryTests { private HttpHeaders headers = new HttpHeaders(); + private Cookies cookies = new Cookies(); + @Override public HttpStatus getStatusCode() throws IOException { return statusCode; @@ -327,5 +337,10 @@ public class InterceptingClientHttpRequestFactoryTests { @Override public void close() { } + + @Override + public Cookies getCookies() { + return this.cookies ; + } } } diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/BinaryMessage.java b/spring-websocket/src/main/java/org/springframework/web/socket/BinaryMessage.java new file mode 100644 index 0000000000..cf6b35594c --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/BinaryMessage.java @@ -0,0 +1,138 @@ +/* + * 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.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.ByteBuffer; + + +/** + * A {@link WebSocketMessage} that contains a binary {@link ByteBuffer} payload. + * + * @author Rossen Stoyanchev + * @since 4.0 + * @see WebSocketMessage + */ +public final class BinaryMessage extends WebSocketMessage { + + private byte[] bytes; + + private final boolean last; + + + /** + * Create a new {@link BinaryMessage} instance. + * @param payload a non-null payload + */ + public BinaryMessage(ByteBuffer payload) { + this(payload, true); + } + + /** + * Create a new {@link BinaryMessage} instance. + * @param payload a non-null payload + * @param isLast if the message is the last of a series of partial messages + */ + public BinaryMessage(ByteBuffer payload, boolean isLast) { + super(payload); + this.bytes = null; + this.last = isLast; + } + + /** + * Create a new {@link BinaryMessage} instance. + * @param payload a non-null payload + */ + public BinaryMessage(byte[] payload) { + this(payload, true); + } + + /** + * Create a new {@link BinaryMessage} instance. + * @param payload a non-null payload + * @param isLast if the message is the last of a series of partial messages + */ + public BinaryMessage(byte[] payload, boolean isLast) { + this(payload, 0, (payload == null ? 0 : payload.length), isLast); + } + + /** + * Create a new {@link BinaryMessage} instance by wrapping an existing byte array. + * @param payload a non-null payload, NOTE: this value is not copied so care must be + * taken not to modify the array. + * @param isLast if the message is the last of a series of partial messages + */ + public BinaryMessage(byte[] payload, int offset, int len) { + this(payload, offset, len, true); + } + + /** + * Create a new {@link BinaryMessage} instance by wrapping an existing byte array. + * @param payload a non-null payload, NOTE: this value is not copied so care must be + * taken not to modify the array. + * @param offset the offet into the array where the payload starts + * @param len the length of the array considered for the payload + * @param isLast if the message is the last of a series of partial messages + */ + public BinaryMessage(byte[] payload, int offset, int len, boolean isLast) { + super(payload != null ? ByteBuffer.wrap(payload, offset, len) : null); + if(offset == 0 && len == payload.length) { + this.bytes = payload; + } + this.last = isLast; + } + + /** + * Returns if this is the last part in a series of partial messages. If this is + * not a partial message this method will return {@code true}. + */ + public boolean isLast() { + return this.last; + } + + /** + * Returns access to the message payload as a byte array. NOTE: the returned array + * should be considered read-only and should not be modified. + */ + public byte[] getByteArray() { + if(this.bytes == null && getPayload() != null) { + this.bytes = getRemainingBytes(getPayload()); + } + return this.bytes; + } + + private byte[] getRemainingBytes(ByteBuffer payload) { + byte[] result = new byte[getPayload().remaining()]; + getPayload().get(result); + return result; + } + + /** + * Returns access to the message payload as an {@link InputStream}. + */ + public InputStream getInputStream() { + byte[] array = getByteArray(); + return (array != null) ? new ByteArrayInputStream(array) : null; + } + + @Override + public String toString() { + int size = (getPayload() != null) ? getPayload().remaining() : 0; + return "WebSocket binary message size=" + size; + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/CloseStatus.java b/spring-websocket/src/main/java/org/springframework/web/socket/CloseStatus.java new file mode 100644 index 0000000000..9e63bdf8e6 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/CloseStatus.java @@ -0,0 +1,210 @@ +/* + * 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 org.eclipse.jetty.websocket.api.StatusCode; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; + +/** + * Represents a WebSocket close status code and reason. Status codes in the 1xxx range are + * pre-defined by the protocol. Optionally, a status code may be sent with a reason. + *

+ * See RFC 6455, Section 7.4.1 + * "Defined Status Codes". + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +public final class CloseStatus { + + /** + * "1000 indicates a normal closure, meaning that the purpose for which the connection + * was established has been fulfilled." + */ + public static final CloseStatus NORMAL = new CloseStatus(1000); + + /** + * "1001 indicates that an endpoint is "going away", such as a server going down or a + * browser having navigated away from a page." + */ + public static final CloseStatus GOING_AWAY = new CloseStatus(1001); + + /** + * "1002 indicates that an endpoint is terminating the connection due to a protocol + * error." + */ + public static final CloseStatus PROTOCOL_ERROR = new CloseStatus(1002); + + /** + * "1003 indicates that an endpoint is terminating the connection because it has + * received a type of data it cannot accept (e.g., an endpoint that understands only + * text data MAY send this if it receives a binary message)." + */ + public static final CloseStatus NOT_ACCEPTABLE = new CloseStatus(1003); + + // 10004: Reserved. + // The specific meaning might be defined in the future. + + /** + * "1005 is a reserved value and MUST NOT be set as a status code in a Close control + * frame by an endpoint. It is designated for use in applications expecting a status + * code to indicate that no status code was actually present." + */ + public static final CloseStatus NO_STATUS_CODE = new CloseStatus(1005); + + /** + * "1006 is a reserved value and MUST NOT be set as a status code in a Close control + * frame by an endpoint. It is designated for use in applications expecting a status + * code to indicate that the connection was closed abnormally, e.g., without sending + * or receiving a Close control frame." + */ + public static final CloseStatus NO_CLOSE_FRAME = new CloseStatus(1006); + + /** + * "1007 indicates that an endpoint is terminating the connection because it has + * received data within a message that was not consistent with the type of the message + * (e.g., non-UTF-8 [RFC3629] data within a text message)." + */ + public static final CloseStatus BAD_DATA = new CloseStatus(1007); + + /** + * "1008 indicates that an endpoint is terminating the connection because it has + * received a message that violates its policy. This is a generic status code that can + * be returned when there is no other more suitable status code (e.g., 1003 or 1009) + * or if there is a need to hide specific details about the policy." + */ + public static final CloseStatus POLICY_VIOLATION = new CloseStatus(1008); + + /** + * "1009 indicates that an endpoint is terminating the connection because it has + * received a message that is too big for it to process." + */ + public static final CloseStatus TOO_BIG_TO_PROCESS = new CloseStatus(1009); + + /** + * "1010 indicates that an endpoint (client) is terminating the connection because it + * has expected the server to negotiate one or more extension, but the server didn't + * return them in the response message of the WebSocket handshake. The list of + * extensions that are needed SHOULD appear in the /reason/ part of the Close frame. + * Note that this status code is not used by the server, because it can fail the + * WebSocket handshake instead." + */ + public static final CloseStatus REQUIRED_EXTENSION = new CloseStatus(1010); + + /** + * "1011 indicates that a server is terminating the connection because it encountered + * an unexpected condition that prevented it from fulfilling the request." + */ + public static final CloseStatus SERVER_ERROR = new CloseStatus(1011); + + /** + * "1012 indicates that the service is restarted. A client may reconnect, and if it + * chooses to do, should reconnect using a randomized delay of 5 - 30s." + */ + public static final CloseStatus SERVICE_RESTARTED = new CloseStatus(1012); + + /** + * "1013 indicates that the service is experiencing overload. A client should only + * connect to a different IP (when there are multiple for the target) or reconnect to + * the same IP upon user action." + */ + public static final CloseStatus SERVICE_OVERLOAD = new CloseStatus(1013); + + /** + * "1015 is a reserved value and MUST NOT be set as a status code in a Close control + * frame by an endpoint. It is designated for use in applications expecting a status + * code to indicate that the connection was closed due to a failure to perform a TLS + * handshake (e.g., the server certificate can't be verified)." + */ + public static final CloseStatus TLS_HANDSHAKE_FAILURE = new CloseStatus(1015); + + + private final int code; + + private final String reason; + + + /** + * Create a new {@link CloseStatus} instance. + * @param code the status code + */ + public CloseStatus(int code) { + this(code, null); + } + + /** + * Create a new {@link CloseStatus} instance. + * @param code + * @param reason + */ + public CloseStatus(int code, String reason) { + Assert.isTrue((code >= 1000 && code < 5000), "Invalid code"); + this.code = code; + this.reason = reason; + } + + /** + * Returns the status code. + */ + public int getCode() { + return this.code; + } + + /** + * Returns the reason or {@code null}. + */ + public String getReason() { + return this.reason; + } + + /** + * Crate a new {@link CloseStatus} from this one with the specified reason. + * @param reason the reason + * @return a new {@link StatusCode} instance + */ + public CloseStatus withReason(String reason) { + Assert.hasText(reason, "Reason must not be empty"); + return new CloseStatus(this.code, reason); + } + + @Override + public int hashCode() { + return this.code * 29 + ObjectUtils.nullSafeHashCode(this.reason); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof CloseStatus)) { + return false; + } + CloseStatus otherStatus = (CloseStatus) other; + return (this.code == otherStatus.code && ObjectUtils.nullSafeEquals(this.reason, otherStatus.reason)); + } + + public boolean equalsCode(CloseStatus other) { + return this.code == other.code; + } + + @Override + public String toString() { + return "CloseStatus [code=" + this.code + ", reason=" + this.reason + "]"; + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/TextMessage.java b/spring-websocket/src/main/java/org/springframework/web/socket/TextMessage.java new file mode 100644 index 0000000000..0bcc9f9b7c --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/TextMessage.java @@ -0,0 +1,45 @@ +/* + * 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.io.Reader; +import java.io.StringReader; + +/** + * A {@link WebSocketMessage} that contains a textual {@link String} payload. + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +public final class TextMessage extends WebSocketMessage { + + /** + * Create a new {@link TextMessage} instance. + * @param payload the payload + */ + public TextMessage(CharSequence payload) { + super(payload.toString()); + } + + /** + * Returns access to the message payload as a {@link Reader}. + */ + public Reader getReader() { + return new StringReader(getPayload()); + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/WebSocketHandler.java b/spring-websocket/src/main/java/org/springframework/web/socket/WebSocketHandler.java new file mode 100644 index 0000000000..ffdcdb2cf3 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/WebSocketHandler.java @@ -0,0 +1,80 @@ +/* + * 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; + +/** + * A handler for WebSocket messages and lifecycle events. + * + *

Implementations of this interface are encouraged to handle exceptions locally where + * it makes sense or alternatively let the exception bubble up in which case the exception + * is logged and the session closed with {@link CloseStatus#SERVER_ERROR SERVER_ERROR(1011)} by default. + * The exception handling strategy is provided by + * {@link org.springframework.web.socket.support.ExceptionWebSocketHandlerDecorator ExceptionWebSocketHandlerDecorator}, + * which can be customized or replaced by decorating the {@link WebSocketHandler} with a + * different decorator. + * + * @param The type of message being handled {@link TextMessage}, {@link BinaryMessage} + * (or {@link WebSocketMessage} for both). + * + * @author Rossen Stoyanchev + * @author Phillip Webb + * @since 4.0 + */ +public interface WebSocketHandler { + + /** + * Invoked after WebSocket negotiation has succeeded and the WebSocket connection is + * opened and ready for use. + * + * @throws Exception this method can handle or propagate exceptions; see class-level + * Javadoc for details. + */ + void afterConnectionEstablished(WebSocketSession session) throws Exception; + + /** + * Invoked when a new WebSocket message arrives. + * + * @throws Exception this method can handle or propagate exceptions; see class-level + * Javadoc for details. + */ + void handleMessage(WebSocketSession session, WebSocketMessage message) throws Exception; + + /** + * Handle an error from the underlying WebSocket message transport. + * + * @throws Exception this method can handle or propagate exceptions; see class-level + * Javadoc for details. + */ + void handleTransportError(WebSocketSession session, Throwable exception) throws Exception; + + /** + * Invoked after the WebSocket connection has been closed by either side, or after a + * transport error has occurred. Although the session may technically still be open, + * depending on the underlying implementation, sending messages at this point is + * discouraged and most likely will not succeed. + * + * @throws Exception this method can handle or propagate exceptions; see class-level + * Javadoc for details. + */ + void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception; + + /** + * Whether this WebSocketHandler wishes to receive messages broken up in parts. + */ + boolean isStreaming(); + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/WebSocketMessage.java b/spring-websocket/src/main/java/org/springframework/web/socket/WebSocketMessage.java new file mode 100644 index 0000000000..329c9d2092 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/WebSocketMessage.java @@ -0,0 +1,76 @@ +/* + * 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 org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; + +/** + * A message that can be handled or sent during a WebSocket interaction. There are only + * two sub-classes {@link BinaryMessage} or a {@link TextMessage} with no further + * sub-classing expected. + * + * @author Rossen Stoyanchev + * @since 4.0 + * @see BinaryMessage + * @see TextMessage + */ +public abstract class WebSocketMessage { + + private final T payload; + + + /** + * Create a new {@link WebSocketMessage} instance with the given payload. + * @param payload a non-null payload + */ + WebSocketMessage(T payload) { + Assert.notNull(payload, "Payload must not be null"); + this.payload = payload; + } + + + /** + * Returns the message payload. This will never be {@code null}. + */ + public T getPayload() { + return this.payload; + } + + @Override + public String toString() { + return getClass().getSimpleName() + " [payload=" + this.payload + "]"; + } + + @Override + public int hashCode() { + return WebSocketMessage.class.hashCode() * 13 + ObjectUtils.nullSafeHashCode(this.payload); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof WebSocketMessage)) { + return false; + } + WebSocketMessage otherMessage = (WebSocketMessage) other; + return ObjectUtils.nullSafeEquals(this.payload, otherMessage.payload); + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/WebSocketSession.java b/spring-websocket/src/main/java/org/springframework/web/socket/WebSocketSession.java new file mode 100644 index 0000000000..c3e119b1f4 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/WebSocketSession.java @@ -0,0 +1,88 @@ +/* + * 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.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.security.Principal; + +/** + * Allows sending messages over a WebSocket connection as well as closing it. + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +public interface WebSocketSession { + + /** + * Return a unique session identifier. + */ + String getId(); + + /** + * Return the URI used to open the WebSocket connection. + */ + URI getUri(); + + /** + * Return whether the underlying socket is using a secure transport. + */ + boolean isSecure(); + + /** + * Return a {@link java.security.Principal} instance containing the name of the + * authenticated user. If the user has not been authenticated, the method returns + * null. + */ + Principal getPrincipal(); + + /** + * Return the host name of the endpoint on the other end. + */ + String getRemoteHostName(); + + /** + * Return the IP address of the endpoint on the other end. + */ + String getRemoteAddress(); + + /** + * Return whether the connection is still open. + */ + boolean isOpen(); + + /** + * Send a WebSocket message either {@link TextMessage} or + * {@link BinaryMessage}. + */ + void sendMessage(WebSocketMessage message) throws IOException; + + /** + * Close the WebSocket connection with status 1000, i.e. equivalent to: + *

+	 * session.close(CloseStatus.NORMAL);
+	 * 
+ */ + void close() throws IOException; + + /** + * Close the WebSocket connection with the given close status. + */ + void close(CloseStatus status) throws IOException; + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/adapter/AbstractWebSocketSesssionAdapter.java b/spring-websocket/src/main/java/org/springframework/web/socket/adapter/AbstractWebSocketSesssionAdapter.java new file mode 100644 index 0000000000..606c37ac7a --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/adapter/AbstractWebSocketSesssionAdapter.java @@ -0,0 +1,84 @@ +/* + * + * 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.io.IOException; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.util.Assert; +import org.springframework.web.socket.BinaryMessage; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketMessage; +import org.springframework.web.socket.WebSocketSession; + + +/** + * An base class for implementations adapting {@link WebSocketSession}. + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +public abstract class AbstractWebSocketSesssionAdapter implements ConfigurableWebSocketSession { + + protected final Log logger = LogFactory.getLog(getClass()); + + + public abstract void initSession(T session); + + @Override + public final void sendMessage(WebSocketMessage message) throws IOException { + if (logger.isTraceEnabled()) { + logger.trace("Sending " + message + ", " + this); + } + Assert.isTrue(isOpen(), "Cannot send message after connection closed."); + if (message instanceof TextMessage) { + sendTextMessage((TextMessage) message); + } + else if (message instanceof BinaryMessage) { + sendBinaryMessage((BinaryMessage) message); + } + else { + throw new IllegalStateException("Unexpected WebSocketMessage type: " + message); + } + } + + protected abstract void sendTextMessage(TextMessage message) throws IOException ; + + protected abstract void sendBinaryMessage(BinaryMessage message) throws IOException ; + + @Override + public void close() throws IOException { + close(CloseStatus.NORMAL); + } + + @Override + public final void close(CloseStatus status) throws IOException { + if (logger.isDebugEnabled()) { + logger.debug("Closing " + this); + } + closeInternal(status); + } + + protected abstract void closeInternal(CloseStatus status) throws IOException; + + @Override + public String toString() { + return "WebSocket session id=" + getId(); + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/adapter/BinaryWebSocketHandlerAdapter.java b/spring-websocket/src/main/java/org/springframework/web/socket/adapter/BinaryWebSocketHandlerAdapter.java new file mode 100644 index 0000000000..3f0bef0012 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/adapter/BinaryWebSocketHandlerAdapter.java @@ -0,0 +1,47 @@ +/* + * 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.io.IOException; + +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.WebSocketSession; + + +/** + * A {@link WebSocketHandler} for binary messages with empty methods. + * + * @author Rossen Stoyanchev + * @author Phillip Webb + * @since 4.0 + */ +public class BinaryWebSocketHandlerAdapter extends WebSocketHandlerAdapter { + + + @Override + protected void handleTextMessage(WebSocketSession session, TextMessage message) { + try { + session.close(CloseStatus.NOT_ACCEPTABLE.withReason("Text messages not supported")); + } + catch (IOException e) { + // ignore + } + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/adapter/ConfigurableWebSocketSession.java b/spring-websocket/src/main/java/org/springframework/web/socket/adapter/ConfigurableWebSocketSession.java new file mode 100644 index 0000000000..f894640a60 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/adapter/ConfigurableWebSocketSession.java @@ -0,0 +1,39 @@ +/* + * 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.net.URI; +import java.security.Principal; + +import org.springframework.web.socket.WebSocketSession; + + +/** + * @author Rossen Stoyanchev + * @since 4.0 + */ +public interface ConfigurableWebSocketSession extends WebSocketSession { + + void setUri(URI uri); + + void setRemoteHostName(String name); + + void setRemoteAddress(String address); + + void setPrincipal(Principal principal); + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/adapter/JettyWebSocketListenerAdapter.java b/spring-websocket/src/main/java/org/springframework/web/socket/adapter/JettyWebSocketListenerAdapter.java new file mode 100644 index 0000000000..e33359ff04 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/adapter/JettyWebSocketListenerAdapter.java @@ -0,0 +1,107 @@ +/* + * 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 org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.WebSocketListener; +import org.springframework.util.Assert; +import org.springframework.web.socket.BinaryMessage; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.support.ExceptionWebSocketHandlerDecorator; + +/** + * Adapts Spring's {@link WebSocketHandler} to Jetty's {@link WebSocketListener}. + * + * @author Phillip Webb + * @since 4.0 + */ +public class JettyWebSocketListenerAdapter implements WebSocketListener { + + private static final Log logger = LogFactory.getLog(JettyWebSocketListenerAdapter.class); + + private final WebSocketHandler webSocketHandler; + + private JettyWebSocketSessionAdapter wsSession; + + + public JettyWebSocketListenerAdapter(WebSocketHandler webSocketHandler, JettyWebSocketSessionAdapter wsSession) { + Assert.notNull(webSocketHandler, "webSocketHandler is required"); + Assert.notNull(wsSession, "wsSession is required"); + this.webSocketHandler = webSocketHandler; + this.wsSession = wsSession; + } + + + @Override + public void onWebSocketConnect(Session session) { + this.wsSession.initSession(session); + try { + this.webSocketHandler.afterConnectionEstablished(this.wsSession); + } + catch (Throwable t) { + ExceptionWebSocketHandlerDecorator.tryCloseWithError(this.wsSession, t, logger); + } + } + + @Override + public void onWebSocketText(String payload) { + TextMessage message = new TextMessage(payload); + try { + this.webSocketHandler.handleMessage(this.wsSession, message); + } + catch (Throwable t) { + ExceptionWebSocketHandlerDecorator.tryCloseWithError(this.wsSession, t, logger); + } + } + + @Override + public void onWebSocketBinary(byte[] payload, int offset, int len) { + BinaryMessage message = new BinaryMessage(payload, offset, len); + try { + this.webSocketHandler.handleMessage(this.wsSession, message); + } + catch (Throwable t) { + ExceptionWebSocketHandlerDecorator.tryCloseWithError(this.wsSession, t, logger); + } + } + + @Override + public void onWebSocketClose(int statusCode, String reason) { + CloseStatus closeStatus = new CloseStatus(statusCode, reason); + try { + this.webSocketHandler.afterConnectionClosed(this.wsSession, closeStatus); + } + catch (Throwable t) { + logger.error("Unhandled error for " + this.wsSession, t); + } + } + + @Override + public void onWebSocketError(Throwable cause) { + try { + this.webSocketHandler.handleTransportError(this.wsSession, cause); + } + catch (Throwable t) { + ExceptionWebSocketHandlerDecorator.tryCloseWithError(this.wsSession, t, logger); + } + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/adapter/JettyWebSocketSessionAdapter.java b/spring-websocket/src/main/java/org/springframework/web/socket/adapter/JettyWebSocketSessionAdapter.java new file mode 100644 index 0000000000..8a3a4e3403 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/adapter/JettyWebSocketSessionAdapter.java @@ -0,0 +1,124 @@ +/* + * 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.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.security.Principal; + +import org.eclipse.jetty.websocket.api.Session; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; +import org.springframework.web.socket.BinaryMessage; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; + + +/** + * Adapts Jetty's {@link Session} to Spring's {@link WebSocketSession}. + * + * @author Phillip Webb + * @author Rossen Stoyanchev + * @since 4.0 + */ +public class JettyWebSocketSessionAdapter + extends AbstractWebSocketSesssionAdapter { + + private Session session; + + private Principal principal; + + + @Override + public void initSession(Session session) { + Assert.notNull(session, "session is required"); + this.session = session; + } + + @Override + public String getId() { + return ObjectUtils.getIdentityHexString(this.session); + } + + @Override + public boolean isSecure() { + return this.session.isSecure(); + } + + @Override + public URI getUri() { + return this.session.getUpgradeRequest().getRequestURI(); + } + + @Override + public void setUri(URI uri) { + } + + @Override + public Principal getPrincipal() { + return this.principal; + } + + @Override + public void setPrincipal(Principal principal) { + this.principal = principal; + } + + @Override + public String getRemoteHostName() { + return this.session.getRemoteAddress().getHostName(); + } + + @Override + public void setRemoteHostName(String address) { + // ignore + } + + @Override + public String getRemoteAddress() { + InetSocketAddress address = this.session.getRemoteAddress(); + return address.isUnresolved() ? null : address.getAddress().getHostAddress(); + } + + @Override + public void setRemoteAddress(String address) { + // ignore + } + + @Override + public boolean isOpen() { + return this.session.isOpen(); + } + + @Override + protected void sendTextMessage(TextMessage message) throws IOException { + this.session.getRemote().sendString(message.getPayload()); + } + + @Override + protected void sendBinaryMessage(BinaryMessage message) throws IOException { + this.session.getRemote().sendBytes(message.getPayload()); + } + + @Override + protected void closeInternal(CloseStatus status) throws IOException { + this.session.close(status.getCode(), status.getReason()); + } + +} \ No newline at end of file diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/adapter/StandardEndpointAdapter.java b/spring-websocket/src/main/java/org/springframework/web/socket/adapter/StandardEndpointAdapter.java new file mode 100644 index 0000000000..7d27878015 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/adapter/StandardEndpointAdapter.java @@ -0,0 +1,139 @@ +/* + * 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.CloseReason; +import javax.websocket.Endpoint; +import javax.websocket.EndpointConfig; +import javax.websocket.MessageHandler; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.util.Assert; +import org.springframework.web.socket.BinaryMessage; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.support.ExceptionWebSocketHandlerDecorator; + + +/** + * A wrapper around a {@link WebSocketHandler} that adapts it to {@link Endpoint}. + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +public class StandardEndpointAdapter extends Endpoint { + + private static final Log logger = LogFactory.getLog(StandardEndpointAdapter.class); + + private final WebSocketHandler handler; + + private final StandardWebSocketSessionAdapter wsSession; + + + public StandardEndpointAdapter(WebSocketHandler handler, StandardWebSocketSessionAdapter wsSession) { + Assert.notNull(handler, "handler is required"); + Assert.notNull(wsSession, "wsSession is required"); + this.handler = handler; + this.wsSession = wsSession; + } + + + @Override + public void onOpen(final javax.websocket.Session session, EndpointConfig config) { + + this.wsSession.initSession(session); + + try { + this.handler.afterConnectionEstablished(this.wsSession); + } + catch (Throwable t) { + ExceptionWebSocketHandlerDecorator.tryCloseWithError(this.wsSession, t, logger); + return; + } + + session.addMessageHandler(new MessageHandler.Whole() { + @Override + public void onMessage(String message) { + handleTextMessage(session, message); + } + }); + + if (!this.handler.isStreaming()) { + session.addMessageHandler(new MessageHandler.Whole() { + @Override + public void onMessage(ByteBuffer message) { + handleBinaryMessage(session, message, true); + } + }); + } + else { + session.addMessageHandler(new MessageHandler.Partial() { + @Override + public void onMessage(ByteBuffer messagePart, boolean isLast) { + handleBinaryMessage(session, messagePart, isLast); + } + }); + } + + } + + private void handleTextMessage(javax.websocket.Session session, String payload) { + TextMessage textMessage = new TextMessage(payload); + try { + this.handler.handleMessage(this.wsSession, textMessage); + } + catch (Throwable t) { + ExceptionWebSocketHandlerDecorator.tryCloseWithError(this.wsSession, t, logger); + } + } + + private void handleBinaryMessage(javax.websocket.Session session, ByteBuffer payload, boolean isLast) { + BinaryMessage binaryMessage = new BinaryMessage(payload, isLast); + try { + this.handler.handleMessage(this.wsSession, binaryMessage); + } + catch (Throwable t) { + ExceptionWebSocketHandlerDecorator.tryCloseWithError(this.wsSession, t, logger); + } + } + + @Override + public void onClose(javax.websocket.Session session, CloseReason reason) { + CloseStatus closeStatus = new CloseStatus(reason.getCloseCode().getCode(), reason.getReasonPhrase()); + try { + this.handler.afterConnectionClosed(this.wsSession, closeStatus); + } + catch (Throwable t) { + logger.error("Unhandled error for " + this.wsSession, t); + } + } + + @Override + public void onError(javax.websocket.Session session, Throwable exception) { + try { + this.handler.handleTransportError(this.wsSession, exception); + } + catch (Throwable t) { + ExceptionWebSocketHandlerDecorator.tryCloseWithError(this.wsSession, t, logger); + } + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/adapter/StandardWebSocketSessionAdapter.java b/spring-websocket/src/main/java/org/springframework/web/socket/adapter/StandardWebSocketSessionAdapter.java new file mode 100644 index 0000000000..8c5ccaff90 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/adapter/StandardWebSocketSessionAdapter.java @@ -0,0 +1,126 @@ +/* + * 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.io.IOException; +import java.net.URI; +import java.security.Principal; + +import javax.websocket.CloseReason; +import javax.websocket.CloseReason.CloseCodes; + +import org.springframework.util.Assert; +import org.springframework.web.socket.BinaryMessage; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; + +/** + * A standard Java implementation of {@link WebSocketSession} that delegates to + * {@link javax.websocket.Session}. + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +public class StandardWebSocketSessionAdapter extends AbstractWebSocketSesssionAdapter { + + private javax.websocket.Session session; + + private URI uri; + + private String remoteHostName; + + private String remoteAddress; + + + public void initSession(javax.websocket.Session session) { + Assert.notNull(session, "session is required"); + this.session = session; + } + + @Override + public String getId() { + return this.session.getId(); + } + + @Override + public URI getUri() { + return this.uri; + } + + @Override + public void setUri(URI uri) { + this.uri = uri; + } + + + @Override + public boolean isSecure() { + return this.session.isSecure(); + } + + @Override + public Principal getPrincipal() { + return this.session.getUserPrincipal(); + } + + @Override + public void setPrincipal(Principal principal) { + // ignore + } + + @Override + public String getRemoteHostName() { + return this.remoteHostName; + } + + @Override + public void setRemoteHostName(String name) { + this.remoteHostName = name; + } + + @Override + public String getRemoteAddress() { + return this.remoteAddress; + } + + @Override + public void setRemoteAddress(String address) { + this.remoteAddress = address; + } + + @Override + public boolean isOpen() { + return this.session.isOpen(); + } + + @Override + protected void sendTextMessage(TextMessage message) throws IOException { + this.session.getBasicRemote().sendText(message.getPayload()); + } + + @Override + protected void sendBinaryMessage(BinaryMessage message) throws IOException { + this.session.getBasicRemote().sendBinary(message.getPayload()); + } + + @Override + protected void closeInternal(CloseStatus status) throws IOException { + this.session.close(new CloseReason(CloseCodes.getCloseCode(status.getCode()), status.getReason())); + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/adapter/TextWebSocketHandlerAdapter.java b/spring-websocket/src/main/java/org/springframework/web/socket/adapter/TextWebSocketHandlerAdapter.java new file mode 100644 index 0000000000..f207885f38 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/adapter/TextWebSocketHandlerAdapter.java @@ -0,0 +1,47 @@ +/* + * 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.io.IOException; + +import org.springframework.web.socket.BinaryMessage; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.WebSocketSession; + + +/** + * A {@link WebSocketHandler} for text messages with empty methods. + * + * @author Rossen Stoyanchev + * @author Phillip Webb + * @since 4.0 + */ +public class TextWebSocketHandlerAdapter extends WebSocketHandlerAdapter { + + + @Override + protected void handleBinaryMessage(WebSocketSession session, BinaryMessage message) { + try { + session.close(CloseStatus.NOT_ACCEPTABLE.withReason("Binary messages not supported")); + } + catch (IOException e) { + // ignore + } + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/adapter/WebSocketHandlerAdapter.java b/spring-websocket/src/main/java/org/springframework/web/socket/adapter/WebSocketHandlerAdapter.java new file mode 100644 index 0000000000..7b97ca5876 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/adapter/WebSocketHandlerAdapter.java @@ -0,0 +1,76 @@ +/* + * 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 org.springframework.web.socket.BinaryMessage; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.WebSocketMessage; +import org.springframework.web.socket.WebSocketSession; + + +/** + * A {@link WebSocketHandler} for both text and binary messages with empty methods. + * + * @author Rossen Stoyanchev + * @author Phillip Webb + * @since 4.0 + * + * @see TextWebSocketHandlerAdapter + * @see BinaryWebSocketHandlerAdapter + */ +public class WebSocketHandlerAdapter implements WebSocketHandler { + + @Override + public void afterConnectionEstablished(WebSocketSession session) throws Exception { + } + + @Override + public final void handleMessage(WebSocketSession session, WebSocketMessage message) throws Exception { + if (message instanceof TextMessage) { + handleTextMessage(session, (TextMessage) message); + } + else if (message instanceof BinaryMessage) { + handleBinaryMessage(session, (BinaryMessage) message); + } + else { + // should not happen + throw new IllegalStateException("Unexpected WebSocket message type: " + message); + } + } + + protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { + } + + protected void handleBinaryMessage(WebSocketSession session, BinaryMessage message) throws Exception { + } + + @Override + public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { + } + + @Override + public boolean isStreaming() { + return false; + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/adapter/package-info.java b/spring-websocket/src/main/java/org/springframework/web/socket/adapter/package-info.java new file mode 100644 index 0000000000..c3e015ceed --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/adapter/package-info.java @@ -0,0 +1,23 @@ +/* + * 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. + */ + +/** + * Classes adapting Spring's WebSocket API classes to and from various WebSocket + * implementations. Also contains convenient base classes for + * {@link org.springframework.web.socket.WebSocketHandler} implementations. + */ +package org.springframework.web.socket.adapter; + diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/client/ConnectionManagerSupport.java b/spring-websocket/src/main/java/org/springframework/web/socket/client/ConnectionManagerSupport.java new file mode 100644 index 0000000000..6cfe503d61 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/client/ConnectionManagerSupport.java @@ -0,0 +1,178 @@ +/* + * 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.client; + +import java.net.URI; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.context.SmartLifecycle; +import org.springframework.core.task.SimpleAsyncTaskExecutor; +import org.springframework.core.task.TaskExecutor; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * Abstract base class for WebSocketConnection managers. + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +public abstract class ConnectionManagerSupport implements SmartLifecycle { + + protected final Log logger = LogFactory.getLog(getClass()); + + + private final URI uri; + + private boolean autoStartup = false; + + private boolean isRunning = false; + + private int phase = Integer.MAX_VALUE; + + private TaskExecutor taskExecutor = new SimpleAsyncTaskExecutor("EndpointConnectionManager-"); + + private final Object lifecycleMonitor = new Object(); + + + public ConnectionManagerSupport(String uriTemplate, Object... uriVariables) { + this.uri = UriComponentsBuilder.fromUriString(uriTemplate).buildAndExpand(uriVariables).encode().toUri(); + } + + /** + * Set whether to auto-connect to the remote endpoint after this connection manager + * has been initialized and the Spring context has been refreshed. + *

Default is "false". + */ + public void setAutoStartup(boolean autoStartup) { + this.autoStartup = autoStartup; + } + + /** + * Return the value for the 'autoStartup' property. If "true", this endpoint + * connection manager will connect to the remote endpoint upon a + * ContextRefreshedEvent. + */ + public boolean isAutoStartup() { + return this.autoStartup; + } + + /** + * Specify the phase in which a connection should be established to the remote + * endpoint and subsequently closed. The startup order proceeds from lowest to + * highest, and the shutdown order is the reverse of that. By default this value is + * Integer.MAX_VALUE meaning that this endpoint connection factory connects as late as + * possible and is closed as soon as possible. + */ + public void setPhase(int phase) { + this.phase = phase; + } + + /** + * Return the phase in which this endpoint connection factory will be auto-connected + * and stopped. + */ + public int getPhase() { + return this.phase; + } + + protected URI getUri() { + return this.uri; + } + + /** + * Return whether this ConnectionManager has been started. + */ + public boolean isRunning() { + synchronized (this.lifecycleMonitor) { + return this.isRunning; + } + } + + /** + * Connect to the configured {@link #setDefaultUri(URI) default URI}. If already + * connected, the method has no impact. + */ + public final void start() { + synchronized (this.lifecycleMonitor) { + if (!isRunning()) { + startInternal(); + } + } + } + + protected void startInternal() { + if (logger.isDebugEnabled()) { + logger.debug("Starting " + this.getClass().getSimpleName()); + } + this.isRunning = true; + this.taskExecutor.execute(new Runnable() { + @Override + public void run() { + synchronized (lifecycleMonitor) { + try { + logger.info("Connecting to WebSocket at " + uri); + openConnection(); + logger.info("Successfully connected"); + } + catch (Throwable ex) { + logger.error("Failed to connect", ex); + } + } + } + }); + } + + protected abstract void openConnection() throws Exception; + + public final void stop() { + synchronized (this.lifecycleMonitor) { + if (isRunning()) { + stopInternal(); + } + } + } + + protected void stopInternal() { + if (logger.isDebugEnabled()) { + logger.debug("Stopping " + this.getClass().getSimpleName()); + } + try { + if (isConnected()) { + closeConnection(); + } + } + catch (Throwable e) { + logger.error("Failed to stop WebSocket connection", e); + } + finally { + this.isRunning = false; + } + } + + protected abstract boolean isConnected(); + + protected abstract void closeConnection() throws Exception; + + public final void stop(Runnable callback) { + synchronized (this.lifecycleMonitor) { + this.stop(); + callback.run(); + } + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/client/WebSocketClient.java b/spring-websocket/src/main/java/org/springframework/web/socket/client/WebSocketClient.java new file mode 100644 index 0000000000..9bef4eefb2 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/client/WebSocketClient.java @@ -0,0 +1,44 @@ +/* + * 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.client; + +import java.net.URI; + +import org.springframework.http.HttpHeaders; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.WebSocketSession; + +/** + * Contract for programmatically starting a WebSocket handshake request. For most cases it + * would be more convenient to use the declarative style + * {@link WebSocketConnectionManager} that starts a WebSocket connection to a + * pre-configured URI when the application starts. + * + * @author Rossen Stoyanchev + * @since 4.0 + * + * @see WebSocketConnectionManager + */ +public interface WebSocketClient { + + + WebSocketSession doHandshake(WebSocketHandler webSocketHandler, + String uriTemplate, Object... uriVariables) throws WebSocketConnectFailureException; + + WebSocketSession doHandshake(WebSocketHandler webSocketHandler, HttpHeaders headers, URI uri) + throws WebSocketConnectFailureException; + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/client/WebSocketConnectFailureException.java b/spring-websocket/src/main/java/org/springframework/web/socket/client/WebSocketConnectFailureException.java new file mode 100644 index 0000000000..184a3e195a --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/client/WebSocketConnectFailureException.java @@ -0,0 +1,37 @@ +/* + * 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.client; + +import org.springframework.core.NestedRuntimeException; + +/** + * @author Rossen Stoyanchev + * @since 4.0 + */ +@SuppressWarnings("serial") +public class WebSocketConnectFailureException extends NestedRuntimeException { + + + public WebSocketConnectFailureException(String msg, Throwable cause) { + super(msg, cause); + } + + public WebSocketConnectFailureException(String msg) { + super(msg); + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/client/WebSocketConnectionManager.java b/spring-websocket/src/main/java/org/springframework/web/socket/client/WebSocketConnectionManager.java new file mode 100644 index 0000000000..1ee44acf41 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/client/WebSocketConnectionManager.java @@ -0,0 +1,111 @@ +/* + * 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.client; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.context.SmartLifecycle; +import org.springframework.http.HttpHeaders; +import org.springframework.util.CollectionUtils; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.support.ExceptionWebSocketHandlerDecorator; +import org.springframework.web.socket.support.LoggingWebSocketHandlerDecorator; + +/** + * @author Rossen Stoyanchev + * @since 4.0 + */ +public class WebSocketConnectionManager extends ConnectionManagerSupport { + + private final WebSocketClient client; + + private final WebSocketHandler webSocketHandler; + + private WebSocketSession webSocketSession; + + private final List subProtocols = new ArrayList(); + + private final boolean syncClientLifecycle; + + + public WebSocketConnectionManager(WebSocketClient client, + WebSocketHandler webSocketHandler, String uriTemplate, Object... uriVariables) { + + super(uriTemplate, uriVariables); + this.client = client; + this.webSocketHandler = decorateWebSocketHandler(webSocketHandler); + this.syncClientLifecycle = ((client instanceof SmartLifecycle) && !((SmartLifecycle) client).isRunning()); + } + + /** + * Decorate the WebSocketHandler provided to the class constructor. + *

+ * By default {@link ExceptionWebSocketHandlerDecorator} and + * {@link LoggingWebSocketHandlerDecorator} are applied are added. + */ + protected WebSocketHandler decorateWebSocketHandler(WebSocketHandler handler) { + handler = new ExceptionWebSocketHandlerDecorator(handler); + return new LoggingWebSocketHandlerDecorator(handler); + } + + public void setSubProtocols(List subProtocols) { + this.subProtocols.clear(); + if (!CollectionUtils.isEmpty(subProtocols)) { + this.subProtocols.addAll(subProtocols); + } + } + + public List getSubProtocols() { + return this.subProtocols; + } + + @Override + public void startInternal() { + if (this.syncClientLifecycle) { + ((SmartLifecycle) this.client).start(); + } + super.startInternal(); + } + + @Override + public void stopInternal() { + if (this.syncClientLifecycle) { + ((SmartLifecycle) client).stop(); + } + super.stopInternal(); + } + + @Override + protected void openConnection() throws Exception { + HttpHeaders headers = new HttpHeaders(); + headers.setSecWebSocketProtocol(this.subProtocols); + this.webSocketSession = this.client.doHandshake(this.webSocketHandler, headers, getUri()); + } + + @Override + protected void closeConnection() throws Exception { + this.webSocketSession.close(); + } + + @Override + protected boolean isConnected() { + return ((this.webSocketSession != null) && (this.webSocketSession.isOpen())); + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/client/endpoint/AnnotatedEndpointConnectionManager.java b/spring-websocket/src/main/java/org/springframework/web/socket/client/endpoint/AnnotatedEndpointConnectionManager.java new file mode 100644 index 0000000000..36e713edca --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/client/endpoint/AnnotatedEndpointConnectionManager.java @@ -0,0 +1,95 @@ +/* + * 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.client.endpoint; + +import javax.websocket.ContainerProvider; +import javax.websocket.Session; +import javax.websocket.WebSocketContainer; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.web.socket.client.ConnectionManagerSupport; +import org.springframework.web.socket.support.BeanCreatingHandlerProvider; + +/** + * @author Rossen Stoyanchev + * @since 4.0 + */ +public class AnnotatedEndpointConnectionManager extends ConnectionManagerSupport implements BeanFactoryAware { + + private final Object endpoint; + + private final BeanCreatingHandlerProvider endpointProvider; + + private WebSocketContainer webSocketContainer = ContainerProvider.getWebSocketContainer(); + + private Session session; + + + public AnnotatedEndpointConnectionManager(Object endpoint, String uriTemplate, Object... uriVariables) { + super(uriTemplate, uriVariables); + this.endpointProvider = null; + this.endpoint = endpoint; + } + + public AnnotatedEndpointConnectionManager(Class endpointClass, String uriTemplate, Object... uriVariables) { + super(uriTemplate, uriVariables); + this.endpointProvider = new BeanCreatingHandlerProvider(endpointClass); + this.endpoint = null; + } + + + public void setWebSocketContainer(WebSocketContainer webSocketContainer) { + this.webSocketContainer = webSocketContainer; + } + + public WebSocketContainer getWebSocketContainer() { + return this.webSocketContainer; + } + + @Override + public void setBeanFactory(BeanFactory beanFactory) throws BeansException { + if (this.endpointProvider != null) { + this.endpointProvider.setBeanFactory(beanFactory); + } + } + + @Override + protected void openConnection() throws Exception { + Object endpoint = (this.endpoint != null) ? this.endpoint : this.endpointProvider.getHandler(); + this.session = this.webSocketContainer.connectToServer(endpoint, getUri()); + } + + @Override + protected void closeConnection() throws Exception { + try { + if (isConnected()) { + this.session.close(); + } + } + finally { + this.session = null; + } + } + + @Override + protected boolean isConnected() { + return ((this.session != null) && this.session.isOpen()); + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/client/endpoint/EndpointConnectionManager.java b/spring-websocket/src/main/java/org/springframework/web/socket/client/endpoint/EndpointConnectionManager.java new file mode 100644 index 0000000000..92fc73b683 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/client/endpoint/EndpointConnectionManager.java @@ -0,0 +1,130 @@ +/* + * 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.client.endpoint; + +import java.util.Arrays; +import java.util.List; + +import javax.websocket.ClientEndpointConfig; +import javax.websocket.ClientEndpointConfig.Configurator; +import javax.websocket.ContainerProvider; +import javax.websocket.Decoder; +import javax.websocket.Encoder; +import javax.websocket.Endpoint; +import javax.websocket.Extension; +import javax.websocket.Session; +import javax.websocket.WebSocketContainer; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.util.Assert; +import org.springframework.web.socket.client.ConnectionManagerSupport; +import org.springframework.web.socket.support.BeanCreatingHandlerProvider; + +/** + * @author Rossen Stoyanchev + * @since 4.0 + */ +public class EndpointConnectionManager extends ConnectionManagerSupport implements BeanFactoryAware { + + private final Endpoint endpoint; + + private final BeanCreatingHandlerProvider endpointProvider; + + private final ClientEndpointConfig.Builder configBuilder = ClientEndpointConfig.Builder.create(); + + private WebSocketContainer webSocketContainer = ContainerProvider.getWebSocketContainer(); + + private Session session; + + + public EndpointConnectionManager(Endpoint endpoint, String uriTemplate, Object... uriVariables) { + super(uriTemplate, uriVariables); + Assert.notNull(endpoint, "endpoint is required"); + this.endpointProvider = null; + this.endpoint = endpoint; + } + + public EndpointConnectionManager(Class endpointClass, String uriTemplate, Object... uriVars) { + super(uriTemplate, uriVars); + Assert.notNull(endpointClass, "endpointClass is required"); + this.endpointProvider = new BeanCreatingHandlerProvider(endpointClass); + this.endpoint = null; + } + + + public void setSubProtocols(String... subprotocols) { + this.configBuilder.preferredSubprotocols(Arrays.asList(subprotocols)); + } + + public void setExtensions(Extension... extensions) { + this.configBuilder.extensions(Arrays.asList(extensions)); + } + + public void setEncoders(List> encoders) { + this.configBuilder.encoders(encoders); + } + + public void setDecoders(List> decoders) { + this.configBuilder.decoders(decoders); + } + + public void setConfigurator(Configurator configurator) { + this.configBuilder.configurator(configurator); + } + + public void setWebSocketContainer(WebSocketContainer webSocketContainer) { + this.webSocketContainer = webSocketContainer; + } + + public WebSocketContainer getWebSocketContainer() { + return this.webSocketContainer; + } + + @Override + public void setBeanFactory(BeanFactory beanFactory) throws BeansException { + if (this.endpointProvider != null) { + this.endpointProvider.setBeanFactory(beanFactory); + } + } + + @Override + protected void openConnection() throws Exception { + Endpoint endpoint = (this.endpoint != null) ? this.endpoint : this.endpointProvider.getHandler(); + ClientEndpointConfig endpointConfig = this.configBuilder.build(); + this.session = getWebSocketContainer().connectToServer(endpoint, endpointConfig, getUri()); + } + + @Override + protected void closeConnection() throws Exception { + try { + if (isConnected()) { + this.session.close(); + } + } + finally { + this.session = null; + } + } + + @Override + protected boolean isConnected() { + return ((this.session != null) && this.session.isOpen()); + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/client/endpoint/StandardWebSocketClient.java b/spring-websocket/src/main/java/org/springframework/web/socket/client/endpoint/StandardWebSocketClient.java new file mode 100644 index 0000000000..21678e12ef --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/client/endpoint/StandardWebSocketClient.java @@ -0,0 +1,124 @@ +/* + * 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.client.endpoint; + +import java.net.URI; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import javax.websocket.ClientEndpointConfig; +import javax.websocket.ClientEndpointConfig.Configurator; +import javax.websocket.ContainerProvider; +import javax.websocket.Endpoint; +import javax.websocket.HandshakeResponse; +import javax.websocket.WebSocketContainer; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.adapter.StandardEndpointAdapter; +import org.springframework.web.socket.adapter.StandardWebSocketSessionAdapter; +import org.springframework.web.socket.client.WebSocketClient; +import org.springframework.web.socket.client.WebSocketConnectFailureException; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * A standard Java {@link WebSocketClient}. + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +public class StandardWebSocketClient implements WebSocketClient { + + private static final Log logger = LogFactory.getLog(StandardWebSocketClient.class); + + private static final Set EXCLUDED_HEADERS = new HashSet( + Arrays.asList("Sec-WebSocket-Accept", "Sec-WebSocket-Extensions", "Sec-WebSocket-Key", + "Sec-WebSocket-Protocol", "Sec-WebSocket-Version")); + + private WebSocketContainer webSocketContainer = ContainerProvider.getWebSocketContainer(); + + + public void setWebSocketContainer(WebSocketContainer container) { + this.webSocketContainer = container; + } + + @Override + public WebSocketSession doHandshake(WebSocketHandler webSocketHandler, String uriTemplate, Object... uriVariables) + throws WebSocketConnectFailureException { + + UriComponents uriComponents = UriComponentsBuilder.fromUriString(uriTemplate).buildAndExpand(uriVariables).encode(); + return doHandshake(webSocketHandler, null, uriComponents); + } + + @Override + public WebSocketSession doHandshake(WebSocketHandler webSocketHandler, + final HttpHeaders httpHeaders, URI uri) throws WebSocketConnectFailureException { + + StandardWebSocketSessionAdapter session = new StandardWebSocketSessionAdapter(); + session.setUri(uri); + session.setRemoteHostName(uri.getHost()); + Endpoint endpoint = new StandardEndpointAdapter(webSocketHandler, session); + + ClientEndpointConfig.Builder configBuidler = ClientEndpointConfig.Builder.create(); + if (httpHeaders != null) { + List protocols = httpHeaders.getSecWebSocketProtocol(); + if (!protocols.isEmpty()) { + configBuidler.preferredSubprotocols(protocols); + } + configBuidler.configurator(new Configurator() { + @Override + public void beforeRequest(Map> headers) { + for (String headerName : httpHeaders.keySet()) { + if (!EXCLUDED_HEADERS.contains(headerName)) { + List value = httpHeaders.get(headerName); + if (logger.isTraceEnabled()) { + logger.trace("Adding header [" + headerName + "=" + value + "]"); + } + headers.put(headerName, value); + } + } + if (logger.isTraceEnabled()) { + logger.trace("Handshake request headers: " + headers); + } + } + @Override + public void afterResponse(HandshakeResponse handshakeResponse) { + if (logger.isTraceEnabled()) { + logger.trace("Handshake response headers: " + handshakeResponse.getHeaders()); + } + } + }); + } + + try { + // TODO: do not block + this.webSocketContainer.connectToServer(endpoint, configBuidler.build(), uri); + return session; + } + catch (Exception e) { + throw new WebSocketConnectFailureException("Failed to connect to " + uri, e); + } + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/client/endpoint/WebSocketContainerFactoryBean.java b/spring-websocket/src/main/java/org/springframework/web/socket/client/endpoint/WebSocketContainerFactoryBean.java new file mode 100644 index 0000000000..10dd2c71df --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/client/endpoint/WebSocketContainerFactoryBean.java @@ -0,0 +1,84 @@ +/* + * 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.client.endpoint; + +import javax.websocket.ContainerProvider; +import javax.websocket.WebSocketContainer; + +import org.springframework.beans.factory.FactoryBean; + +/** + * A FactoryBean for creating and configuring a {@link javax.websocket.WebSocketContainer} + * through Spring XML configuration. In Java configuration, ignore this class and use + * {@code ContainerProvider.getWebSocketContainer()} instead. + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +public class WebSocketContainerFactoryBean implements FactoryBean { + + private final WebSocketContainer webSocketContainer = ContainerProvider.getWebSocketContainer(); + + + public void setAsyncSendTimeout(long timeoutInMillis) { + this.webSocketContainer.setAsyncSendTimeout(timeoutInMillis); + } + + public long getAsyncSendTimeout() { + return this.webSocketContainer.getDefaultAsyncSendTimeout(); + } + + public void setMaxSessionIdleTimeout(long timeoutInMillis) { + this.webSocketContainer.setDefaultMaxSessionIdleTimeout(timeoutInMillis); + } + + public long getMaxSessionIdleTimeout() { + return this.webSocketContainer.getDefaultMaxSessionIdleTimeout(); + } + + public void setMaxTextMessageBufferSize(int bufferSize) { + this.webSocketContainer.setDefaultMaxTextMessageBufferSize(bufferSize); + } + + public int getMaxTextMessageBufferSize() { + return this.webSocketContainer.getDefaultMaxTextMessageBufferSize(); + } + + public void setMaxBinaryMessageBufferSize(int bufferSize) { + this.webSocketContainer.setDefaultMaxBinaryMessageBufferSize(bufferSize); + } + + public int getMaxBinaryMessageBufferSize() { + return this.webSocketContainer.getDefaultMaxBinaryMessageBufferSize(); + } + + @Override + public WebSocketContainer getObject() throws Exception { + return this.webSocketContainer; + } + + @Override + public Class getObjectType() { + return WebSocketContainer.class; + } + + @Override + public boolean isSingleton() { + return true; + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/client/endpoint/package-info.java b/spring-websocket/src/main/java/org/springframework/web/socket/client/endpoint/package-info.java new file mode 100644 index 0000000000..c0904051a0 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/client/endpoint/package-info.java @@ -0,0 +1,24 @@ +/* + * 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. + */ + +/** + * Client-side classes for use with standard Java WebSocket endpoints including + * {@link org.springframework.web.socket.client.endpoint.EndpointConnectionManager} and + * {@link org.springframework.web.socket.client.endpoint.AnnotatedEndpointConnectionManager} + * for connecting to server endpoints using type-based or annotated endpoints respectively. + */ +package org.springframework.web.socket.client.endpoint; + diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/client/jetty/JettyWebSocketClient.java b/spring-websocket/src/main/java/org/springframework/web/socket/client/jetty/JettyWebSocketClient.java new file mode 100644 index 0000000000..d5bb04ddfd --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/client/jetty/JettyWebSocketClient.java @@ -0,0 +1,154 @@ +/* + * 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.client.jetty; + +import java.net.URI; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.context.SmartLifecycle; +import org.springframework.http.HttpHeaders; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.adapter.JettyWebSocketListenerAdapter; +import org.springframework.web.socket.adapter.JettyWebSocketSessionAdapter; +import org.springframework.web.socket.client.WebSocketClient; +import org.springframework.web.socket.client.WebSocketConnectFailureException; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; + + +/** + * @author Rossen Stoyanchev + * @since 4.0 + */ +public class JettyWebSocketClient implements WebSocketClient, SmartLifecycle { + + private static final Log logger = LogFactory.getLog(JettyWebSocketClient.class); + + private final org.eclipse.jetty.websocket.client.WebSocketClient client; + + private boolean autoStartup = true; + + private int phase = Integer.MAX_VALUE; + + private final Object lifecycleMonitor = new Object(); + + + public JettyWebSocketClient() { + this.client = new org.eclipse.jetty.websocket.client.WebSocketClient(); + } + + + // TODO: configure Jetty WebSocketClient properties + + public void setAutoStartup(boolean autoStartup) { + this.autoStartup = autoStartup; + } + + @Override + public boolean isAutoStartup() { + return this.autoStartup; + } + + public void setPhase(int phase) { + this.phase = phase; + } + + @Override + public int getPhase() { + return this.phase; + } + + @Override + public boolean isRunning() { + synchronized (this.lifecycleMonitor) { + return this.client.isStarted(); + } + } + + @Override + public void start() { + synchronized (this.lifecycleMonitor) { + if (!isRunning()) { + try { + if (logger.isDebugEnabled()) { + logger.debug("Starting Jetty WebSocketClient"); + } + this.client.start(); + } + catch (Exception e) { + throw new IllegalStateException("Failed to start Jetty client", e); + } + } + } + } + + @Override + public void stop() { + synchronized (this.lifecycleMonitor) { + if (isRunning()) { + try { + if (logger.isDebugEnabled()) { + logger.debug("Stopping Jetty WebSocketClient"); + } + this.client.stop(); + } + catch (Exception e) { + logger.error("Error stopping Jetty WebSocketClient", e); + } + } + } + } + + @Override + public void stop(Runnable callback) { + this.stop(); + callback.run(); + } + + @Override + public WebSocketSession doHandshake(WebSocketHandler webSocketHandler, String uriTemplate, Object... uriVariables) + throws WebSocketConnectFailureException { + + UriComponents uriComponents = UriComponentsBuilder.fromUriString(uriTemplate).buildAndExpand(uriVariables).encode(); + return doHandshake(webSocketHandler, null, uriComponents); + } + + @Override + public WebSocketSession doHandshake(WebSocketHandler webSocketHandler, HttpHeaders headers, URI uri) + throws WebSocketConnectFailureException { + + // TODO: populate headers + + JettyWebSocketSessionAdapter session = new JettyWebSocketSessionAdapter(); + session.setUri(uri); + session.setRemoteHostName(uri.getHost()); + + JettyWebSocketListenerAdapter listener = new JettyWebSocketListenerAdapter(webSocketHandler, session); + + try { + // TODO: do not block + this.client.connect(listener, uri).get(); + return session; + } + catch (Exception e) { + throw new WebSocketConnectFailureException("Failed to connect to " + uri, e); + } + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/client/package-info.java b/spring-websocket/src/main/java/org/springframework/web/socket/client/package-info.java new file mode 100644 index 0000000000..3304705b56 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/client/package-info.java @@ -0,0 +1,21 @@ +/* + * 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. + */ + +/** + * Server-side abstractions for WebSocket applications. + */ +package org.springframework.web.socket.client; + diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/package-info.java b/spring-websocket/src/main/java/org/springframework/web/socket/package-info.java new file mode 100644 index 0000000000..e5f39989c2 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/package-info.java @@ -0,0 +1,21 @@ +/* + * 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. + */ + +/** + * Common abstractions and Spring configuration support for WebSocket applications. + */ +package org.springframework.web.socket; + diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/server/DefaultHandshakeHandler.java b/spring-websocket/src/main/java/org/springframework/web/socket/server/DefaultHandshakeHandler.java new file mode 100644 index 0000000000..f3948b795e --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/server/DefaultHandshakeHandler.java @@ -0,0 +1,251 @@ +/* + * 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.server; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import javax.xml.bind.DatatypeConverter; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.beans.BeanUtils; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.util.ClassUtils; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.socket.WebSocketHandler; + +/** + * TODO + *

+ * A container-specific {@link RequestUpgradeStrategy} is required since standard Java + * WebSocket currently does not provide a way to initiate a WebSocket handshake. + * Currently available are implementations for Tomcat and GlassFish. + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +public class DefaultHandshakeHandler implements HandshakeHandler { + + private static final String GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + + protected Log logger = LogFactory.getLog(getClass()); + + private List supportedProtocols = new ArrayList(); + + private RequestUpgradeStrategy requestUpgradeStrategy; + + + /** + * Default constructor that auto-detects and instantiates a + * {@link RequestUpgradeStrategy} suitable for the runtime container. + * + * @throws IllegalStateException if no {@link RequestUpgradeStrategy} can be found. + */ + public DefaultHandshakeHandler() { + this.requestUpgradeStrategy = new RequestUpgradeStrategyFactory().create(); + } + + /** + * A constructor that accepts a runtime specific {@link RequestUpgradeStrategy}. + * @param upgradeStrategy the upgrade strategy + */ + public DefaultHandshakeHandler(RequestUpgradeStrategy upgradeStrategy) { + this.requestUpgradeStrategy = upgradeStrategy; + } + + + public void setSupportedProtocols(String... protocols) { + this.supportedProtocols = Arrays.asList(protocols); + } + + public String[] getSupportedProtocols() { + return this.supportedProtocols.toArray(new String[this.supportedProtocols.size()]); + } + + @Override + public final boolean doHandshake(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler webSocketHandler) throws IOException, HandshakeFailureException { + + logger.debug("Starting handshake for " + request.getURI()); + + if (!HttpMethod.GET.equals(request.getMethod())) { + response.setStatusCode(HttpStatus.METHOD_NOT_ALLOWED); + response.getHeaders().setAllow(Collections.singleton(HttpMethod.GET)); + logger.debug("Only HTTP GET is allowed, current method is " + request.getMethod()); + return false; + } + if (!"WebSocket".equalsIgnoreCase(request.getHeaders().getUpgrade())) { + handleInvalidUpgradeHeader(request, response); + return false; + } + if (!request.getHeaders().getConnection().contains("Upgrade") && + !request.getHeaders().getConnection().contains("upgrade")) { + handleInvalidConnectHeader(request, response); + return false; + } + if (!isWebSocketVersionSupported(request)) { + handleWebSocketVersionNotSupported(request, response); + return false; + } + if (!isValidOrigin(request)) { + response.setStatusCode(HttpStatus.FORBIDDEN); + return false; + } + String wsKey = request.getHeaders().getSecWebSocketKey(); + if (wsKey == null) { + logger.debug("Missing \"Sec-WebSocket-Key\" header"); + response.setStatusCode(HttpStatus.BAD_REQUEST); + return false; + } + + String selectedProtocol = selectProtocol(request.getHeaders().getSecWebSocketProtocol()); + // TODO: select extensions + + logger.debug("Upgrading HTTP request"); + + response.setStatusCode(HttpStatus.SWITCHING_PROTOCOLS); + response.getHeaders().setUpgrade("WebSocket"); + response.getHeaders().setConnection("Upgrade"); + response.getHeaders().setSecWebSocketProtocol(selectedProtocol); + response.getHeaders().setSecWebSocketAccept(getWebSocketKeyHash(wsKey)); + // TODO: response.getHeaders().setSecWebSocketExtensions(extensions); + + response.flush(); + + if (logger.isTraceEnabled()) { + logger.trace("Upgrading with " + webSocketHandler); + } + + this.requestUpgradeStrategy.upgrade(request, response, selectedProtocol, webSocketHandler); + + return true; + } + + protected void handleInvalidUpgradeHeader(ServerHttpRequest request, ServerHttpResponse response) throws IOException { + logger.debug("Invalid Upgrade header " + request.getHeaders().getUpgrade()); + response.setStatusCode(HttpStatus.BAD_REQUEST); + response.getBody().write("Can \"Upgrade\" only to \"WebSocket\".".getBytes("UTF-8")); + } + + protected void handleInvalidConnectHeader(ServerHttpRequest request, ServerHttpResponse response) throws IOException { + logger.debug("Invalid Connection header " + request.getHeaders().getConnection()); + response.setStatusCode(HttpStatus.BAD_REQUEST); + response.getBody().write("\"Connection\" must be \"upgrade\".".getBytes("UTF-8")); + } + + protected boolean isWebSocketVersionSupported(ServerHttpRequest request) { + String requestedVersion = request.getHeaders().getSecWebSocketVersion(); + for (String supportedVersion : getSupportedVerions()) { + if (supportedVersion.equals(requestedVersion)) { + return true; + } + } + return false; + } + + protected String[] getSupportedVerions() { + return this.requestUpgradeStrategy.getSupportedVersions(); + } + + protected void handleWebSocketVersionNotSupported(ServerHttpRequest request, ServerHttpResponse response) { + logger.debug("WebSocket version not supported " + request.getHeaders().get("Sec-WebSocket-Version")); + response.setStatusCode(HttpStatus.UPGRADE_REQUIRED); + response.getHeaders().setSecWebSocketVersion(StringUtils.arrayToCommaDelimitedString(getSupportedVerions())); + } + + protected boolean isValidOrigin(ServerHttpRequest request) { + String origin = request.getHeaders().getOrigin(); + if (origin != null) { + // UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromHttpUrl(origin); + // TODO + // A simple strategy checks against the current request's scheme/port/host + // Or match scheme, port, and host against configured allowed origins (wild cards for hosts?) + // return false; + } + return true; + } + + protected String selectProtocol(List requestedProtocols) { + if (CollectionUtils.isEmpty(requestedProtocols)) { + for (String protocol : requestedProtocols) { + if (this.supportedProtocols.contains(protocol)) { + return protocol; + } + } + } + return null; + } + + private String getWebSocketKeyHash(String key) throws HandshakeFailureException { + try { + MessageDigest digest = MessageDigest.getInstance("SHA1"); + byte[] bytes = digest.digest((key + GUID).getBytes(Charset.forName("ISO-8859-1"))); + return DatatypeConverter.printBase64Binary(bytes); + } + catch (NoSuchAlgorithmException ex) { + throw new HandshakeFailureException("Failed to generate value for Sec-WebSocket-Key header", ex); + } + } + + + private static class RequestUpgradeStrategyFactory { + + private static final boolean tomcatWebSocketPresent = ClassUtils.isPresent( + "org.apache.tomcat.websocket.server.WsHttpUpgradeHandler", DefaultHandshakeHandler.class.getClassLoader()); + + private static final boolean glassFishWebSocketPresent = ClassUtils.isPresent( + "org.glassfish.tyrus.servlet.TyrusHttpUpgradeHandler", DefaultHandshakeHandler.class.getClassLoader()); + + private static final boolean jettyWebSocketPresent = ClassUtils.isPresent( + "org.eclipse.jetty.websocket.server.UpgradeContext", DefaultHandshakeHandler.class.getClassLoader()); + + private RequestUpgradeStrategy create() { + String className; + if (tomcatWebSocketPresent) { + className = "org.springframework.web.socket.server.support.TomcatRequestUpgradeStrategy"; + } + else if (glassFishWebSocketPresent) { + className = "org.springframework.web.socket.server.support.GlassFishRequestUpgradeStrategy"; + } + else if (jettyWebSocketPresent) { + className = "org.springframework.web.socket.server.support.JettyRequestUpgradeStrategy"; + } + else { + throw new IllegalStateException("No suitable " + RequestUpgradeStrategy.class.getSimpleName()); + } + try { + Class clazz = ClassUtils.forName(className, DefaultHandshakeHandler.class.getClassLoader()); + return (RequestUpgradeStrategy) BeanUtils.instantiateClass(clazz.getConstructor()); + } + catch (Throwable t) { + throw new IllegalStateException("Failed to instantiate " + className, t); + } + } + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/server/HandshakeFailureException.java b/spring-websocket/src/main/java/org/springframework/web/socket/server/HandshakeFailureException.java new file mode 100644 index 0000000000..9284737818 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/server/HandshakeFailureException.java @@ -0,0 +1,48 @@ +/* + * 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.server; + +import org.springframework.core.NestedRuntimeException; + + +/** + * Thrown when handshake processing failed to complete due to an internal, unrecoverable + * error. This implies a server error (HTTP status code 500) as opposed to a failure in + * the handshake negotiation. + * + *

+ * By contrast, when handshake negotiation fails, the response status code will be 200 and + * the response headers and body will have been updated to reflect the cause for the + * failure. A {@link HandshakeHandler} implementation will have protected methods to + * customize updates to the response in those cases. + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +@SuppressWarnings("serial") +public class HandshakeFailureException extends NestedRuntimeException { + + + public HandshakeFailureException(String msg, Throwable cause) { + super(msg, cause); + } + + public HandshakeFailureException(String msg) { + super(msg); + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/server/HandshakeHandler.java b/spring-websocket/src/main/java/org/springframework/web/socket/server/HandshakeHandler.java new file mode 100644 index 0000000000..867812b160 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/server/HandshakeHandler.java @@ -0,0 +1,51 @@ +/* + * 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.server; + +import java.io.IOException; + +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.web.socket.WebSocketHandler; + +/** + * Contract for processing a WebSocket handshake request. + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +public interface HandshakeHandler { + + + /** + * + * @param request + * @param response + * @param webSocketHandler + * @return + * + * @throws IOException thrown when accessing or setting the response + * + * @throws HandshakeFailureException thrown when handshake processing failed to + * complete due to an internal, unrecoverable error, i.e. a server error as + * opposed to a failure to successfully negotiate the requirements of the + * handshake request. + */ + boolean doHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler webSocketHandler) + throws IOException, HandshakeFailureException; + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/server/RequestUpgradeStrategy.java b/spring-websocket/src/main/java/org/springframework/web/socket/server/RequestUpgradeStrategy.java new file mode 100644 index 0000000000..1421f51984 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/server/RequestUpgradeStrategy.java @@ -0,0 +1,53 @@ +/* + * 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.server; + +import java.io.IOException; + +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.web.socket.WebSocketHandler; + +/** + * A strategy for performing container-specific steps to upgrade an HTTP request during a + * WebSocket handshake. Intended for use within {@link HandshakeHandler} implementations. + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +public interface RequestUpgradeStrategy { + + /** + * Return the supported WebSocket protocol versions. + */ + String[] getSupportedVersions(); + + /** + * Perform runtime specific steps to complete the upgrade. Invoked after successful + * negotiation of the handshake request. + * + * @param webSocketHandler the handler for WebSocket messages + * + * @throws HandshakeFailureException thrown when handshake processing failed to + * complete due to an internal, unrecoverable error, i.e. a server error as + * opposed to a failure to successfully negotiate the requirements of the + * handshake request. + */ + void upgrade(ServerHttpRequest request, ServerHttpResponse response, String selectedProtocol, + WebSocketHandler webSocketHandler) throws IOException, HandshakeFailureException; + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/server/endpoint/EndpointExporter.java b/spring-websocket/src/main/java/org/springframework/web/socket/server/endpoint/EndpointExporter.java new file mode 100644 index 0000000000..0e1ce1029e --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/server/endpoint/EndpointExporter.java @@ -0,0 +1,151 @@ +/* + * 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.server.endpoint; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import javax.websocket.DeploymentException; +import javax.websocket.server.ServerContainer; +import javax.websocket.server.ServerEndpoint; +import javax.websocket.server.ServerEndpointConfig; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.ReflectionUtils; + +/** + * BeanPostProcessor that detects beans of type + * {@link javax.websocket.server.ServerEndpointConfig} and registers the provided + * {@link javax.websocket.Endpoint} with a standard Java WebSocket runtime. + * + *

If the runtime is a Servlet container, use {@link ServletEndpointExporter}. + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +public class EndpointExporter implements InitializingBean, BeanPostProcessor, ApplicationContextAware { + + private static final boolean isServletApiPresent = + ClassUtils.isPresent("javax.servlet.ServletContext", EndpointExporter.class.getClassLoader()); + + private static Log logger = LogFactory.getLog(EndpointExporter.class); + + private final List> annotatedEndpointClasses = new ArrayList>(); + + private final List> annotatedEndpointBeanTypes = new ArrayList>(); + + private ApplicationContext applicationContext; + + private ServerContainer serverContainer; + + /** + * TODO + * @param annotatedEndpointClasses + */ + public void setAnnotatedEndpointClasses(Class... annotatedEndpointClasses) { + this.annotatedEndpointClasses.clear(); + this.annotatedEndpointClasses.addAll(Arrays.asList(annotatedEndpointClasses)); + } + + @Override + public void setApplicationContext(ApplicationContext applicationContext) { + + this.applicationContext = applicationContext; + + this.serverContainer = getServerContainer(); + + Map beans = applicationContext.getBeansWithAnnotation(ServerEndpoint.class); + for (String beanName : beans.keySet()) { + Class beanType = applicationContext.getType(beanName); + if (logger.isInfoEnabled()) { + logger.info("Detected @ServerEndpoint bean '" + beanName + "', registering it as an endpoint by type"); + } + this.annotatedEndpointBeanTypes.add(beanType); + } + } + + protected ServerContainer getServerContainer() { + if (isServletApiPresent) { + try { + Method getter = ReflectionUtils.findMethod(this.applicationContext.getClass(), "getServletContext"); + Object servletContext = getter.invoke(this.applicationContext); + + Method attrMethod = ReflectionUtils.findMethod(servletContext.getClass(), "getAttribute", String.class); + return (ServerContainer) attrMethod.invoke(servletContext, "javax.websocket.server.ServerContainer"); + } + catch (Exception ex) { + throw new IllegalStateException( + "Failed to get javax.websocket.server.ServerContainer via ServletContext attribute", ex); + } + } + return null; + } + + @Override + public void afterPropertiesSet() throws Exception { + + Assert.notNull(serverContainer, "javax.websocket.server.ServerContainer not available"); + + List> allClasses = new ArrayList>(this.annotatedEndpointClasses); + allClasses.addAll(this.annotatedEndpointBeanTypes); + + for (Class clazz : allClasses) { + try { + logger.info("Registering @ServerEndpoint type " + clazz); + this.serverContainer.addEndpoint(clazz); + } + catch (DeploymentException e) { + throw new IllegalStateException("Failed to register @ServerEndpoint type " + clazz, e); + } + } + } + + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + if (bean instanceof ServerEndpointConfig) { + ServerEndpointConfig sec = (ServerEndpointConfig) bean; + try { + if (logger.isInfoEnabled()) { + logger.info("Registering bean '" + beanName + + "' as javax.websocket.Endpoint under path " + sec.getPath()); + } + getServerContainer().addEndpoint(sec); + } + catch (DeploymentException e) { + throw new IllegalStateException("Failed to deploy Endpoint bean " + bean, e); + } + } + return bean; + } + + @Override + public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { + return bean; + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/server/endpoint/EndpointRegistration.java b/spring-websocket/src/main/java/org/springframework/web/socket/server/endpoint/EndpointRegistration.java new file mode 100644 index 0000000000..40ec854e65 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/server/endpoint/EndpointRegistration.java @@ -0,0 +1,196 @@ +/* + * 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.server.endpoint; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.websocket.Decoder; +import javax.websocket.Encoder; +import javax.websocket.Endpoint; +import javax.websocket.Extension; +import javax.websocket.HandshakeResponse; +import javax.websocket.server.HandshakeRequest; +import javax.websocket.server.ServerEndpointConfig; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.util.Assert; +import org.springframework.web.socket.support.BeanCreatingHandlerProvider; + + +/** + * An implementation of {@link javax.websocket.server.ServerEndpointConfig} that also + * holds the target {@link javax.websocket.Endpoint} as a reference or a bean name. + * + *

+ * Beans of this type are detected by {@link EndpointExporter} and + * registered with a Java WebSocket runtime at startup. + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +public class EndpointRegistration implements ServerEndpointConfig, BeanFactoryAware { + + private final String path; + + private final BeanCreatingHandlerProvider endpointProvider; + + private final Endpoint endpoint; + + private List> encoders = new ArrayList>(); + + private List> decoders = new ArrayList>(); + + private List subprotocols = new ArrayList(); + + private List extensions = new ArrayList(); + + private final Map userProperties = new HashMap(); + + private Configurator configurator = new Configurator() {}; + + + /** + * Class constructor with the {@code javax.webscoket.Endpoint} class. + * + * @param path + * @param endpointClass + */ + public EndpointRegistration(String path, Class endpointClass) { + Assert.hasText(path, "path must not be empty"); + Assert.notNull(endpointClass, "endpointClass is required"); + this.path = path; + this.endpointProvider = new BeanCreatingHandlerProvider(endpointClass); + this.endpoint = null; + } + + public EndpointRegistration(String path, Endpoint endpoint) { + Assert.hasText(path, "path must not be empty"); + Assert.notNull(endpoint, "endpoint is required"); + this.path = path; + this.endpointProvider = null; + this.endpoint = endpoint; + } + + + @Override + public String getPath() { + return this.path; + } + + @Override + public Class getEndpointClass() { + return (this.endpoint != null) ? + this.endpoint.getClass() : ((Class) this.endpointProvider.getHandlerType()); + } + + public Endpoint getEndpoint() { + return (this.endpoint != null) ? this.endpoint : this.endpointProvider.getHandler(); + } + + public void setSubprotocols(List subprotocols) { + this.subprotocols = subprotocols; + } + + @Override + public List getSubprotocols() { + return this.subprotocols; + } + + public void setExtensions(List extensions) { + this.extensions = extensions; + } + + @Override + public List getExtensions() { + return this.extensions; + } + + public void setUserProperties(Map userProperties) { + this.userProperties.clear(); + this.userProperties.putAll(userProperties); + } + + @Override + public Map getUserProperties() { + return this.userProperties; + } + + public void setEncoders(List> encoders) { + this.encoders = encoders; + } + + @Override + public List> getEncoders() { + return this.encoders; + } + + public void setDecoders(List> decoders) { + this.decoders = decoders; + } + + @Override + public List> getDecoders() { + return this.decoders; + } + + /** + * The {@link Configurator#getEndpointInstance(Class)} method is always ignored. + */ + public void setConfigurator(Configurator configurator) { + this.configurator = configurator; + } + + @Override + public Configurator getConfigurator() { + return new Configurator() { + @SuppressWarnings("unchecked") + @Override + public T getEndpointInstance(Class clazz) throws InstantiationException { + return (T) EndpointRegistration.this.getEndpoint(); + } + @Override + public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) { + EndpointRegistration.this.configurator.modifyHandshake(sec, request, response); + } + @Override + public boolean checkOrigin(String originHeaderValue) { + return EndpointRegistration.this.configurator.checkOrigin(originHeaderValue); + } + @Override + public String getNegotiatedSubprotocol(List supported, List requested) { + return EndpointRegistration.this.configurator.getNegotiatedSubprotocol(supported, requested); + } + @Override + public List getNegotiatedExtensions(List installed, List requested) { + return EndpointRegistration.this.configurator.getNegotiatedExtensions(installed, requested); + } + }; + } + + @Override + public void setBeanFactory(BeanFactory beanFactory) throws BeansException { + if (this.endpointProvider != null) { + this.endpointProvider.setBeanFactory(beanFactory); + } + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/server/endpoint/ServletServerContainerFactoryBean.java b/spring-websocket/src/main/java/org/springframework/web/socket/server/endpoint/ServletServerContainerFactoryBean.java new file mode 100644 index 0000000000..7ea6cefe7f --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/server/endpoint/ServletServerContainerFactoryBean.java @@ -0,0 +1,133 @@ +/* + * 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.server.endpoint; + +import javax.servlet.ServletContext; +import javax.websocket.WebSocketContainer; +import javax.websocket.server.ServerContainer; + +import org.springframework.beans.factory.FactoryBean; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.util.Assert; +import org.springframework.web.context.ServletContextAware; +import org.springframework.web.socket.server.DefaultHandshakeHandler; +import org.springframework.web.socket.sockjs.SockJsService; + +/** + * A FactoryBean for {@link javax.websocket.server.ServerContainer}. Since + * there is only one {@code ServerContainer} instance accessible under a well-known + * {@code javax.servlet.ServletContext} attribute, simply declaring this FactoryBean and + * using its setters allows configuring the {@code ServerContainer} through Spring + * configuration. This is useful even if the ServerContainer is not injected into any + * other bean. For example, an application can configure a {@link DefaultHandshakeHandler} + * , a {@link SockJsService}, or {@link EndpointExporter}, and separately declare this + * FactoryBean in order to customize the properties of the (one and only) + * {@code ServerContainer} instance. + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +public class ServletServerContainerFactoryBean + implements FactoryBean, InitializingBean, ServletContextAware { + + private static final String SERVER_CONTAINER_ATTR_NAME = "javax.websocket.server.ServerContainer"; + + + private Long asyncSendTimeout; + + private Long maxSessionIdleTimeout; + + private Integer maxTextMessageBufferSize; + + private Integer maxBinaryMessageBufferSize; + + private ServerContainer serverContainer; + + + public void setAsyncSendTimeout(long timeoutInMillis) { + this.asyncSendTimeout = timeoutInMillis; + } + + public long getAsyncSendTimeout() { + return this.asyncSendTimeout; + } + + public void setMaxSessionIdleTimeout(long timeoutInMillis) { + this.maxSessionIdleTimeout = timeoutInMillis; + } + + public Long getMaxSessionIdleTimeout() { + return this.maxSessionIdleTimeout; + } + + public void setMaxTextMessageBufferSize(int bufferSize) { + this.maxTextMessageBufferSize = bufferSize; + } + + public Integer getMaxTextMessageBufferSize() { + return this.maxTextMessageBufferSize; + } + + public void setMaxBinaryMessageBufferSize(int bufferSize) { + this.maxBinaryMessageBufferSize = bufferSize; + } + + public Integer getMaxBinaryMessageBufferSize() { + return this.maxBinaryMessageBufferSize; + } + + @Override + public void setServletContext(ServletContext servletContext) { + this.serverContainer = (ServerContainer) servletContext.getAttribute(SERVER_CONTAINER_ATTR_NAME); + } + + @Override + public ServerContainer getObject() { + return this.serverContainer; + } + + @Override + public Class getObjectType() { + return ServerContainer.class; + } + + @Override + public boolean isSingleton() { + return false; + } + + @Override + public void afterPropertiesSet() throws Exception { + + Assert.notNull(this.serverContainer, + "A ServletContext is required to access the javax.websocket.server.ServerContainer instance"); + + if (this.asyncSendTimeout != null) { + this.serverContainer.setAsyncSendTimeout(this.asyncSendTimeout); + } + if (this.maxSessionIdleTimeout != null) { + this.serverContainer.setDefaultMaxSessionIdleTimeout(this.maxSessionIdleTimeout); + } + if (this.maxTextMessageBufferSize != null) { + this.serverContainer.setDefaultMaxTextMessageBufferSize(this.maxTextMessageBufferSize); + } + if (this.maxBinaryMessageBufferSize != null) { + this.serverContainer.setDefaultMaxBinaryMessageBufferSize(this.maxBinaryMessageBufferSize); + } + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/server/endpoint/SpringConfigurator.java b/spring-websocket/src/main/java/org/springframework/web/socket/server/endpoint/SpringConfigurator.java new file mode 100644 index 0000000000..dd6efb56ec --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/server/endpoint/SpringConfigurator.java @@ -0,0 +1,73 @@ +/* + * 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.server.endpoint; + +import java.util.Map; + +import javax.websocket.server.ServerEndpoint; +import javax.websocket.server.ServerEndpointConfig.Configurator; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.web.context.ContextLoader; +import org.springframework.web.context.WebApplicationContext; + +/** + * This should be used in conjuction with {@link ServerEndpoint @ServerEndpoint} classes. + * + *

For {@link javax.websocket.Endpoint}, see {@link EndpointExporter}. + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +public class SpringConfigurator extends Configurator { + + private static Log logger = LogFactory.getLog(SpringConfigurator.class); + + + @Override + public T getEndpointInstance(Class endpointClass) throws InstantiationException { + + WebApplicationContext wac = ContextLoader.getCurrentWebApplicationContext(); + if (wac == null) { + String message = "Failed to find the root WebApplicationContext. Was ContextLoaderListener not used?"; + logger.error(message); + throw new IllegalStateException(message); + } + + Map beans = wac.getBeansOfType(endpointClass); + if (beans.isEmpty()) { + if (logger.isTraceEnabled()) { + logger.trace("Creating new @ServerEndpoint instance of type " + endpointClass); + } + return wac.getAutowireCapableBeanFactory().createBean(endpointClass); + } + if (beans.size() == 1) { + if (logger.isTraceEnabled()) { + logger.trace("Using @ServerEndpoint singleton " + beans.keySet().iterator().next()); + } + return beans.values().iterator().next(); + } + else { + // Should not happen .. + String message = "Found more than one matching @ServerEndpoint beans of type " + endpointClass; + logger.error(message); + throw new IllegalStateException(message); + } + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/server/endpoint/package-info.java b/spring-websocket/src/main/java/org/springframework/web/socket/server/endpoint/package-info.java new file mode 100644 index 0000000000..5b5a29efbe --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/server/endpoint/package-info.java @@ -0,0 +1,26 @@ +/* + * 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. + */ + +/** + * Server classes for use with standard Java WebSocket endpoints including + * {@link org.springframework.web.socket.server.endpoint.EndpointRegistration} and + * {@link org.springframework.web.socket.server.endpoint.EndpointExporter} for + * registering type-based endpoints, + * {@link org.springframework.web.socket.server.endpoint.SpringConfigurator} for + * instantiating annotated endpoints through Spring. + */ +package org.springframework.web.socket.server.endpoint; + diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/server/package-info.java b/spring-websocket/src/main/java/org/springframework/web/socket/server/package-info.java new file mode 100644 index 0000000000..b1560a6364 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/server/package-info.java @@ -0,0 +1,21 @@ +/* + * 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. + */ + +/** + * Server-side abstractions for WebSocket applications. + */ +package org.springframework.web.socket.server; + diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/server/support/AbstractEndpointUpgradeStrategy.java b/spring-websocket/src/main/java/org/springframework/web/socket/server/support/AbstractEndpointUpgradeStrategy.java new file mode 100644 index 0000000000..bce86dd46d --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/server/support/AbstractEndpointUpgradeStrategy.java @@ -0,0 +1,60 @@ +/* + * 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.server.support; + +import java.io.IOException; + +import javax.websocket.Endpoint; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.adapter.StandardEndpointAdapter; +import org.springframework.web.socket.adapter.StandardWebSocketSessionAdapter; +import org.springframework.web.socket.server.HandshakeFailureException; +import org.springframework.web.socket.server.RequestUpgradeStrategy; + +/** + * A {@link RequestUpgradeStrategy} that supports WebSocket handlers of type + * {@link WebSocketHandler} as well as {@link javax.websocket.Endpoint}. + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +public abstract class AbstractEndpointUpgradeStrategy implements RequestUpgradeStrategy { + + protected final Log logger = LogFactory.getLog(getClass()); + + private final ServerWebSocketSessionInitializer wsSessionInitializer = new ServerWebSocketSessionInitializer(); + + + @Override + public void upgrade(ServerHttpRequest request, ServerHttpResponse response, + String protocol, WebSocketHandler handler) throws IOException, HandshakeFailureException { + + StandardWebSocketSessionAdapter session = new StandardWebSocketSessionAdapter(); + this.wsSessionInitializer.initialize(request, response, session); + StandardEndpointAdapter endpoint = new StandardEndpointAdapter(handler, session); + upgradeInternal(request, response, protocol, endpoint); + } + + protected abstract void upgradeInternal(ServerHttpRequest request, ServerHttpResponse response, + String selectedProtocol, Endpoint endpoint) throws IOException, HandshakeFailureException; + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/server/support/GlassFishRequestUpgradeStrategy.java b/spring-websocket/src/main/java/org/springframework/web/socket/server/support/GlassFishRequestUpgradeStrategy.java new file mode 100644 index 0000000000..a327e0b6bb --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/server/support/GlassFishRequestUpgradeStrategy.java @@ -0,0 +1,170 @@ +/* + * 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.server.support; + +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.net.URI; +import java.util.Arrays; +import java.util.Random; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpServletResponseWrapper; +import javax.websocket.DeploymentException; +import javax.websocket.Endpoint; + +import org.glassfish.tyrus.core.ComponentProviderService; +import org.glassfish.tyrus.core.EndpointWrapper; +import org.glassfish.tyrus.core.ErrorCollector; +import org.glassfish.tyrus.core.RequestContext; +import org.glassfish.tyrus.server.TyrusEndpoint; +import org.glassfish.tyrus.servlet.TyrusHttpUpgradeHandler; +import org.glassfish.tyrus.websockets.Connection; +import org.glassfish.tyrus.websockets.Version; +import org.glassfish.tyrus.websockets.WebSocketEngine; +import org.glassfish.tyrus.websockets.WebSocketEngine.WebSocketHolderListener; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.http.server.ServletServerHttpResponse; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.ReflectionUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.socket.server.HandshakeFailureException; +import org.springframework.web.socket.server.endpoint.EndpointRegistration; + +/** + * GlassFish support for upgrading an {@link HttpServletRequest} during a WebSocket + * handshake. + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +public class GlassFishRequestUpgradeStrategy extends AbstractEndpointUpgradeStrategy { + + private final static Random random = new Random(); + + + @Override + public String[] getSupportedVersions() { + return StringUtils.commaDelimitedListToStringArray(Version.getSupportedWireProtocolVersions()); + } + + @Override + public void upgradeInternal(ServerHttpRequest request, ServerHttpResponse response, + String selectedProtocol, Endpoint endpoint) throws IOException, HandshakeFailureException { + + Assert.isTrue(request instanceof ServletServerHttpRequest); + HttpServletRequest servletRequest = ((ServletServerHttpRequest) request).getServletRequest(); + + Assert.isTrue(response instanceof ServletServerHttpResponse); + HttpServletResponse servletResponse = ((ServletServerHttpResponse) response).getServletResponse(); + servletResponse = new AlreadyUpgradedResponseWrapper(servletResponse); + + TyrusEndpoint tyrusEndpoint = createTyrusEndpoint(servletRequest, endpoint, selectedProtocol); + WebSocketEngine engine = WebSocketEngine.getEngine(); + + try { + engine.register(tyrusEndpoint); + } + catch (DeploymentException ex) { + throw new HandshakeFailureException("Failed to deploy endpoint in GlassFish", ex); + } + + try { + if (!performUpgrade(servletRequest, servletResponse, request.getHeaders(), tyrusEndpoint)) { + throw new HandshakeFailureException("Failed to upgrade HttpServletRequest"); + } + } + finally { + engine.unregister(tyrusEndpoint); + } + } + + private boolean performUpgrade(HttpServletRequest request, HttpServletResponse response, + HttpHeaders headers, TyrusEndpoint tyrusEndpoint) throws IOException { + + final TyrusHttpUpgradeHandler upgradeHandler = request.upgrade(TyrusHttpUpgradeHandler.class); + + Connection connection = createConnection(upgradeHandler, response); + + RequestContext wsRequest = RequestContext.Builder.create() + .requestURI(URI.create(tyrusEndpoint.getPath())).requestPath(tyrusEndpoint.getPath()) + .connection(connection).secure(request.isSecure()).build(); + + for (String header : headers.keySet()) { + wsRequest.getHeaders().put(header, headers.get(header)); + } + + return WebSocketEngine.getEngine().upgrade(connection, wsRequest, new WebSocketHolderListener() { + @Override + public void onWebSocketHolder(WebSocketEngine.WebSocketHolder webSocketHolder) { + upgradeHandler.setWebSocketHolder(webSocketHolder); + } + }); + } + + private TyrusEndpoint createTyrusEndpoint(HttpServletRequest request, Endpoint endpoint, String selectedProtocol) { + + // Use randomized path + String requestUri = request.getRequestURI(); + String randomValue = String.valueOf(random.nextLong()); + String endpointPath = requestUri.endsWith("/") ? requestUri + randomValue : requestUri + "/" + randomValue; + + EndpointRegistration endpointConfig = new EndpointRegistration(endpointPath, endpoint); + endpointConfig.setSubprotocols(Arrays.asList(selectedProtocol)); + + return new TyrusEndpoint(new EndpointWrapper(endpoint, endpointConfig, + ComponentProviderService.create(), null, "/", new ErrorCollector(), + endpointConfig.getConfigurator())); + } + + private Connection createConnection(TyrusHttpUpgradeHandler handler, HttpServletResponse response) { + try { + String name = "org.glassfish.tyrus.servlet.ConnectionImpl"; + Class clazz = ClassUtils.forName(name, GlassFishRequestUpgradeStrategy.class.getClassLoader()); + Constructor constructor = clazz.getDeclaredConstructor(TyrusHttpUpgradeHandler.class, HttpServletResponse.class); + ReflectionUtils.makeAccessible(constructor); + return (Connection) constructor.newInstance(handler, response); + } + catch (Exception ex) { + throw new IllegalStateException("Failed to instantiate GlassFish connection", ex); + } + } + + + private static class AlreadyUpgradedResponseWrapper extends HttpServletResponseWrapper { + + public AlreadyUpgradedResponseWrapper(HttpServletResponse response) { + super(response); + } + + @Override + public void setStatus(int sc) { + Assert.isTrue(sc == HttpStatus.SWITCHING_PROTOCOLS.value(), "Unexpected status code " + sc); + } + @Override + public void addHeader(String name, String value) { + // ignore + } + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/server/support/JettyRequestUpgradeStrategy.java b/spring-websocket/src/main/java/org/springframework/web/socket/server/support/JettyRequestUpgradeStrategy.java new file mode 100644 index 0000000000..0a428f3c5e --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/server/support/JettyRequestUpgradeStrategy.java @@ -0,0 +1,115 @@ +/* + * 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.server.support; + +import java.io.IOException; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.eclipse.jetty.websocket.api.UpgradeRequest; +import org.eclipse.jetty.websocket.api.UpgradeResponse; +import org.eclipse.jetty.websocket.server.HandshakeRFC6455; +import org.eclipse.jetty.websocket.server.ServletWebSocketRequest; +import org.eclipse.jetty.websocket.server.WebSocketServerFactory; +import org.eclipse.jetty.websocket.servlet.WebSocketCreator; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.http.server.ServletServerHttpResponse; +import org.springframework.util.Assert; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.adapter.JettyWebSocketListenerAdapter; +import org.springframework.web.socket.adapter.JettyWebSocketSessionAdapter; +import org.springframework.web.socket.server.HandshakeFailureException; +import org.springframework.web.socket.server.RequestUpgradeStrategy; + +/** + * {@link RequestUpgradeStrategy} for use with Jetty. Based on Jetty's internal + * {@code org.eclipse.jetty.websocket.server.WebSocketHandler} class. + * + * @author Phillip Webb + * @since 4.0 + */ +public class JettyRequestUpgradeStrategy implements RequestUpgradeStrategy { + + // FIXME jetty has options, timeouts etc. Do we need a common abstraction + + // FIXME need a way for someone to plug their own RequestUpgradeStrategy or override + // Jetty settings + + // FIXME when to call factory.cleanup(); + + private static final String WEBSOCKET_LISTENER_ATTR_NAME = JettyRequestUpgradeStrategy.class.getName() + + ".HANDLER_PROVIDER"; + + private WebSocketServerFactory factory; + + private final ServerWebSocketSessionInitializer wsSessionInitializer = new ServerWebSocketSessionInitializer(); + + + public JettyRequestUpgradeStrategy() { + this.factory = new WebSocketServerFactory(); + this.factory.setCreator(new WebSocketCreator() { + @Override + public Object createWebSocket(UpgradeRequest request, UpgradeResponse response) { + Assert.isInstanceOf(ServletWebSocketRequest.class, request); + return ((ServletWebSocketRequest) request).getServletAttributes().get(WEBSOCKET_LISTENER_ATTR_NAME); + } + }); + try { + this.factory.init(); + } + catch (Exception ex) { + throw new IllegalStateException("Unable to initialize Jetty WebSocketServerFactory", ex); + } + } + + + @Override + public String[] getSupportedVersions() { + return new String[] { String.valueOf(HandshakeRFC6455.VERSION) }; + } + + @Override + public void upgrade(ServerHttpRequest request, ServerHttpResponse response, + String selectedProtocol, WebSocketHandler webSocketHandler) throws IOException { + + Assert.isInstanceOf(ServletServerHttpRequest.class, request); + HttpServletRequest servletRequest = ((ServletServerHttpRequest) request).getServletRequest(); + + Assert.isInstanceOf(ServletServerHttpResponse.class, response); + HttpServletResponse servletResponse = ((ServletServerHttpResponse) response).getServletResponse(); + + if (!this.factory.isUpgradeRequest(servletRequest, servletResponse)) { + // should never happen + throw new HandshakeFailureException("Not a WebSocket request"); + } + + JettyWebSocketSessionAdapter session = new JettyWebSocketSessionAdapter(); + this.wsSessionInitializer.initialize(request, response, session); + JettyWebSocketListenerAdapter listener = new JettyWebSocketListenerAdapter(webSocketHandler, session); + + servletRequest.setAttribute(WEBSOCKET_LISTENER_ATTR_NAME, listener); + + if (!this.factory.acceptWebSocket(servletRequest, servletResponse)) { + // should never happen + throw new HandshakeFailureException("WebSocket request not accepted by Jetty"); + } + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/server/support/ServerWebSocketSessionInitializer.java b/spring-websocket/src/main/java/org/springframework/web/socket/server/support/ServerWebSocketSessionInitializer.java new file mode 100644 index 0000000000..60e1f70784 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/server/support/ServerWebSocketSessionInitializer.java @@ -0,0 +1,38 @@ +/* + * 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.server.support; + +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.web.socket.adapter.ConfigurableWebSocketSession; + + +/** + * @author Rossen Stoyanchev + * @since 4.0 + */ +public class ServerWebSocketSessionInitializer { + + + public void initialize(ServerHttpRequest request, ServerHttpResponse response, ConfigurableWebSocketSession session) { + session.setUri(request.getURI()); + session.setRemoteHostName(request.getRemoteHostName()); + session.setRemoteAddress(request.getRemoteAddress()); + session.setPrincipal(request.getPrincipal()); + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/server/support/TomcatRequestUpgradeStrategy.java b/spring-websocket/src/main/java/org/springframework/web/socket/server/support/TomcatRequestUpgradeStrategy.java new file mode 100644 index 0000000000..1236c36c55 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/server/support/TomcatRequestUpgradeStrategy.java @@ -0,0 +1,79 @@ +/* + * 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.server.support; + +import java.io.IOException; +import java.lang.reflect.Method; +import java.util.Collections; + +import javax.servlet.http.HttpServletRequest; +import javax.websocket.Endpoint; +import javax.websocket.server.ServerEndpointConfig; + +import org.apache.tomcat.websocket.server.WsHandshakeRequest; +import org.apache.tomcat.websocket.server.WsHttpUpgradeHandler; +import org.apache.tomcat.websocket.server.WsServerContainer; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.util.Assert; +import org.springframework.util.ReflectionUtils; +import org.springframework.web.socket.server.HandshakeFailureException; +import org.springframework.web.socket.server.endpoint.EndpointRegistration; + +/** + * Tomcat support for upgrading an {@link HttpServletRequest} during a WebSocket handshake. + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +public class TomcatRequestUpgradeStrategy extends AbstractEndpointUpgradeStrategy { + + @Override + public String[] getSupportedVersions() { + return new String[] { "13" }; + } + + @Override + public void upgradeInternal(ServerHttpRequest request, ServerHttpResponse response, + String selectedProtocol, Endpoint endpoint) throws IOException { + + Assert.isTrue(request instanceof ServletServerHttpRequest); + HttpServletRequest servletRequest = ((ServletServerHttpRequest) request).getServletRequest(); + + WsHttpUpgradeHandler upgradeHandler = servletRequest.upgrade(WsHttpUpgradeHandler.class); + + WsHandshakeRequest webSocketRequest = new WsHandshakeRequest(servletRequest); + try { + Method method = ReflectionUtils.findMethod(WsHandshakeRequest.class, "finished"); + ReflectionUtils.makeAccessible(method); + method.invoke(webSocketRequest); + } + catch (Exception ex) { + throw new HandshakeFailureException("Failed to upgrade HttpServletRequest", ex); + } + + // TODO: use ServletContext attribute when Tomcat is updated + WsServerContainer serverContainer = WsServerContainer.getServerContainer(); + + ServerEndpointConfig endpointConfig = new EndpointRegistration("/shouldntmatter", endpoint); + + upgradeHandler.preInit(endpoint, endpointConfig, serverContainer, webSocketRequest, + selectedProtocol, Collections. emptyMap(), servletRequest.isSecure()); + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/server/support/WebSocketHttpRequestHandler.java b/spring-websocket/src/main/java/org/springframework/web/socket/server/support/WebSocketHttpRequestHandler.java new file mode 100644 index 0000000000..6c0406171f --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/server/support/WebSocketHttpRequestHandler.java @@ -0,0 +1,88 @@ +/* + * 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.server.support; + +import java.io.IOException; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.http.server.ServletServerHttpResponse; +import org.springframework.util.Assert; +import org.springframework.web.HttpRequestHandler; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.DefaultHandshakeHandler; +import org.springframework.web.socket.server.HandshakeHandler; +import org.springframework.web.socket.support.ExceptionWebSocketHandlerDecorator; +import org.springframework.web.socket.support.LoggingWebSocketHandlerDecorator; + +/** + * An {@link HttpRequestHandler} that wraps the invocation of a {@link HandshakeHandler}. + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +public class WebSocketHttpRequestHandler implements HttpRequestHandler { + + private final HandshakeHandler handshakeHandler; + + private final WebSocketHandler webSocketHandler; + + + public WebSocketHttpRequestHandler(WebSocketHandler webSocketHandler) { + this(webSocketHandler, new DefaultHandshakeHandler()); + } + + public WebSocketHttpRequestHandler( WebSocketHandler webSocketHandler, HandshakeHandler handshakeHandler) { + Assert.notNull(webSocketHandler, "webSocketHandler is required"); + Assert.notNull(handshakeHandler, "handshakeHandler is required"); + this.webSocketHandler = decorateWebSocketHandler(webSocketHandler); + this.handshakeHandler = new DefaultHandshakeHandler(); + } + + /** + * Decorate the WebSocketHandler provided to the class constructor. + *

+ * By default {@link ExceptionWebSocketHandlerDecorator} and + * {@link LoggingWebSocketHandlerDecorator} are applied are added. + */ + protected WebSocketHandler decorateWebSocketHandler(WebSocketHandler handler) { + handler = new ExceptionWebSocketHandlerDecorator(handler); + return new LoggingWebSocketHandlerDecorator(handler); + } + + @Override + public void handleRequest(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + + ServerHttpRequest httpRequest = new ServletServerHttpRequest(request); + ServerHttpResponse httpResponse = new ServletServerHttpResponse(response); + + try { + this.handshakeHandler.doHandshake(httpRequest, httpResponse, this.webSocketHandler); + httpResponse.flush(); + } + catch (IOException ex) { + throw ex; + } + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/server/support/package-info.java b/spring-websocket/src/main/java/org/springframework/web/socket/server/support/package-info.java new file mode 100644 index 0000000000..41c16bb7b7 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/server/support/package-info.java @@ -0,0 +1,22 @@ +/* + * 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. + */ + +/** + * Server-side support classes including container-specific strategies for upgrading a + * request. + */ +package org.springframework.web.socket.server.support; + diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/AbstractServerSockJsSession.java b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/AbstractServerSockJsSession.java new file mode 100644 index 0000000000..710e5e7d33 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/AbstractServerSockJsSession.java @@ -0,0 +1,160 @@ +/* + * 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.sockjs; + +import java.io.EOFException; +import java.io.IOException; +import java.net.SocketException; +import java.util.Date; +import java.util.concurrent.ScheduledFuture; + +import org.springframework.util.Assert; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.WebSocketMessage; + +/** + * Provides partial implementations of {@link SockJsSession} methods to send messages, + * including heartbeat messages and to manage session state. + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +public abstract class AbstractServerSockJsSession extends AbstractSockJsSession { + + private final SockJsConfiguration sockJsConfig; + + private ScheduledFuture heartbeatTask; + + + public AbstractServerSockJsSession(String sessionId, SockJsConfiguration config, WebSocketHandler handler) { + super(sessionId, handler); + this.sockJsConfig = config; + } + + protected SockJsConfiguration getSockJsConfig() { + return this.sockJsConfig; + } + + public final synchronized void sendMessage(WebSocketMessage message) throws IOException { + Assert.isTrue(!isClosed(), "Cannot send a message when session is closed"); + Assert.isInstanceOf(TextMessage.class, message, "Expected text message: " + message); + sendMessageInternal(((TextMessage) message).getPayload()); + } + + protected abstract void sendMessageInternal(String message) throws IOException; + + + @Override + public void connectionClosedInternal(CloseStatus status) { + updateLastActiveTime(); + cancelHeartbeat(); + } + + @Override + public final synchronized void closeInternal(CloseStatus status) throws IOException { + if (isActive()) { + // TODO: deliver messages "in flight" before sending close frame + try { + // bypass writeFrame + writeFrameInternal(SockJsFrame.closeFrame(status.getCode(), status.getReason())); + } + catch (Throwable ex) { + logger.warn("Failed to send SockJS close frame: " + ex.getMessage()); + } + } + updateLastActiveTime(); + cancelHeartbeat(); + disconnect(status); + } + + protected abstract void disconnect(CloseStatus status) throws IOException; + + /** + * For internal use within a TransportHandler and the (TransportHandler-specific) + * session sub-class. + */ + protected void writeFrame(SockJsFrame frame) throws IOException { + if (logger.isTraceEnabled()) { + logger.trace("Preparing to write " + frame); + } + try { + writeFrameInternal(frame); + } + catch (IOException ex) { + if (ex instanceof EOFException || ex instanceof SocketException) { + logger.warn("Client went away. Terminating connection"); + } + else { + logger.warn("Terminating connection due to failure to send message: " + ex.getMessage()); + } + disconnect(CloseStatus.SERVER_ERROR); + close(CloseStatus.SERVER_ERROR); + throw ex; + } + catch (Throwable ex) { + logger.warn("Terminating connection due to failure to send message: " + ex.getMessage()); + disconnect(CloseStatus.SERVER_ERROR); + close(CloseStatus.SERVER_ERROR); + throw new SockJsRuntimeException("Failed to write " + frame, ex); + } + } + + protected abstract void writeFrameInternal(SockJsFrame frame) throws Exception; + + public synchronized void sendHeartbeat() throws Exception { + if (isActive()) { + writeFrame(SockJsFrame.heartbeatFrame()); + scheduleHeartbeat(); + } + } + + protected void scheduleHeartbeat() { + Assert.notNull(getSockJsConfig().getTaskScheduler(), "heartbeatScheduler not configured"); + cancelHeartbeat(); + if (!isActive()) { + return; + } + Date time = new Date(System.currentTimeMillis() + getSockJsConfig().getHeartbeatTime()); + this.heartbeatTask = getSockJsConfig().getTaskScheduler().schedule(new Runnable() { + public void run() { + try { + sendHeartbeat(); + } + catch (Throwable t) { + // ignore + } + } + }, time); + if (logger.isTraceEnabled()) { + logger.trace("Scheduled heartbeat after " + getSockJsConfig().getHeartbeatTime()/1000 + " seconds"); + } + } + + protected void cancelHeartbeat() { + if ((this.heartbeatTask != null) && !this.heartbeatTask.isDone()) { + if (logger.isTraceEnabled()) { + logger.trace("Cancelling heartbeat"); + } + this.heartbeatTask.cancel(false); + } + this.heartbeatTask = null; + } + + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/AbstractSockJsService.java b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/AbstractSockJsService.java new file mode 100644 index 0000000000..0abf1cc523 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/AbstractSockJsService.java @@ -0,0 +1,516 @@ +/* + * 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.sockjs; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Random; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.DigestUtils; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.socket.WebSocketHandler; + +/** + * An abstract class for {@link SockJsService} implementations. Provides configuration + * support, SockJS path resolution, and processing for static SockJS requests (e.g. + * "/info", "/iframe.html", etc). Sub-classes are responsible for handling transport + * requests. + * + *

+ * It is expected that this service is mapped correctly to one or more prefixes such as + * "/echo" including all sub-URLs (e.g. "/echo/**"). A SockJS service itself is generally + * unaware of request mapping details but nevertheless must be able to extract the SockJS + * path, which is the portion of the request path following the prefix. In most cases, + * this class can auto-detect the SockJS path but you can also explicitly configure the + * list of valid prefixes with {@link #setValidSockJsPrefixes(String...)}. + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +public abstract class AbstractSockJsService implements SockJsService, SockJsConfiguration { + + protected final Log logger = LogFactory.getLog(getClass()); + + private static final int ONE_YEAR = 365 * 24 * 60 * 60; + + + private String name = "SockJSService@" + ObjectUtils.getIdentityHexString(this); + + private String clientLibraryUrl = "https://d1fxtkz8shb9d2.cloudfront.net/sockjs-0.3.4.min.js"; + + private int streamBytesLimit = 128 * 1024; + + private boolean jsessionIdCookieRequired = true; + + private long heartbeatTime = 25 * 1000; + + private long disconnectDelay = 5 * 1000; + + private boolean webSocketsEnabled = true; + + private final TaskScheduler taskScheduler; + + private final List sockJsPrefixes = new ArrayList(); + + private final Set sockJsPathCache = new CopyOnWriteArraySet(); + + + public AbstractSockJsService(TaskScheduler scheduler) { + Assert.notNull(scheduler, "scheduler is required"); + this.taskScheduler = scheduler; + } + + /** + * A unique name for the service mainly for logging purposes. + */ + public void setName(String name) { + this.name = name; + } + + public String getName() { + return this.name; + } + + /** + * Use this property to configure one or more prefixes that this SockJS service is + * allowed to serve. The prefix (e.g. "/echo") is needed to extract the SockJS + * specific portion of the URL (e.g. "${prefix}/info", "${prefix}/iframe.html", etc). + *

+ * This property is not strictly required. In most cases, the SockJS path can be + * auto-detected since the initial request from the SockJS client is of the form + * "{prefix}/info". Assuming the SockJS service is mapped correctly (e.g. using + * Ant-style pattern "/echo/**") this should work fine. This property can be used + * to configure explicitly the prefixes this service is allowed to service. + * + * @param prefixes the prefixes to use; prefixes do not need to include the portions + * of the path that represent Servlet container context or Servlet path. + */ + public void setValidSockJsPrefixes(String... prefixes) { + + this.sockJsPrefixes.clear(); + for (String prefix : prefixes) { + if (prefix.endsWith("/") && (prefix.length() > 1)) { + prefix = prefix.substring(0, prefix.length() - 1); + } + this.sockJsPrefixes.add(prefix); + } + + // sort with longest prefix at the top + Collections.sort(this.sockJsPrefixes, Collections.reverseOrder(new Comparator() { + public int compare(String o1, String o2) { + return new Integer(o1.length()).compareTo(new Integer(o2.length())); + } + })); + } + + /** + * Transports which don't support cross-domain communication natively (e.g. + * "eventsource", "htmlfile") rely on serving a simple page (using the + * "foreign" domain) from an invisible iframe. Code run from this iframe + * doesn't need to worry about cross-domain issues since it is running from + * a domain local to the SockJS server. The iframe does need to load the + * SockJS javascript client library and this option allows configuring its + * url. + *

+ * By default this is set to point to + * "https://d1fxtkz8shb9d2.cloudfront.net/sockjs-0.3.4.min.js". + */ + public AbstractSockJsService setSockJsClientLibraryUrl(String clientLibraryUrl) { + this.clientLibraryUrl = clientLibraryUrl; + return this; + } + + /** + * The URL to the SockJS JavaScript client library. + * @see #setSockJsClientLibraryUrl(String) + */ + public String getSockJsClientLibraryUrl() { + return this.clientLibraryUrl; + } + + public AbstractSockJsService setStreamBytesLimit(int streamBytesLimit) { + this.streamBytesLimit = streamBytesLimit; + return this; + } + + public int getStreamBytesLimit() { + return streamBytesLimit; + } + + /** + * Some load balancers do sticky sessions, but only if there is a JSESSIONID + * cookie. Even if it is set to a dummy value, it doesn't matter since + * session information is added by the load balancer. + *

+ * Set this option to indicate if a JSESSIONID cookie should be created. The + * default value is "true". + */ + public AbstractSockJsService setJsessionIdCookieRequired(boolean jsessionIdCookieRequired) { + this.jsessionIdCookieRequired = jsessionIdCookieRequired; + return this; + } + + /** + * Whether setting JSESSIONID cookie is necessary. + * @see #setJsessionIdCookieRequired(boolean) + */ + public boolean isJsessionIdCookieRequired() { + return this.jsessionIdCookieRequired; + } + + public AbstractSockJsService setHeartbeatTime(long heartbeatTime) { + this.heartbeatTime = heartbeatTime; + return this; + } + + public long getHeartbeatTime() { + return this.heartbeatTime; + } + + public TaskScheduler getTaskScheduler() { + return this.taskScheduler; + } + + /** + * The amount of time in milliseconds before a client is considered + * disconnected after not having a receiving connection, i.e. an active + * connection over which the server can send data to the client. + *

+ * The default value is 5000. + */ + public void setDisconnectDelay(long disconnectDelay) { + this.disconnectDelay = disconnectDelay; + } + + /** + * Return the amount of time in milliseconds before a client is considered disconnected. + */ + public long getDisconnectDelay() { + return this.disconnectDelay; + } + + /** + * Some load balancers don't support websockets. This option can be used to + * disable the WebSocket transport on the server side. + *

+ * The default value is "true". + */ + public void setWebSocketsEnabled(boolean webSocketsEnabled) { + this.webSocketsEnabled = webSocketsEnabled; + } + + /** + * Whether WebSocket transport is enabled. + * @see #setWebSocketsEnabled(boolean) + */ + public boolean isWebSocketEnabled() { + return this.webSocketsEnabled; + } + + /** + * TODO + * + * @param request + * @param response + * @param sockJsPath + * + * @throws Exception + */ + public final void handleRequest(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler handler) + throws IOException, TransportErrorException { + + String sockJsPath = getSockJsPath(request); + if (sockJsPath == null) { + logger.warn("Could not determine SockJS path for URL \"" + request.getURI().getPath() + + ". Consider setting validSockJsPrefixes."); + response.setStatusCode(HttpStatus.NOT_FOUND); + return; + } + + logger.debug(request.getMethod() + " with SockJS path [" + sockJsPath + "]"); + + try { + request.getHeaders(); + } + catch (IllegalArgumentException ex) { + // Ignore invalid Content-Type (TODO) + } + + try { + if (sockJsPath.equals("") || sockJsPath.equals("/")) { + response.getHeaders().setContentType(new MediaType("text", "plain", Charset.forName("UTF-8"))); + response.getBody().write("Welcome to SockJS!\n".getBytes("UTF-8")); + return; + } + else if (sockJsPath.equals("/info")) { + this.infoHandler.handle(request, response); + return; + } + else if (sockJsPath.matches("/iframe[0-9-.a-z_]*.html")) { + this.iframeHandler.handle(request, response); + return; + } + else if (sockJsPath.equals("/websocket")) { + handleRawWebSocketRequest(request, response, handler); + return; + } + + String[] pathSegments = StringUtils.tokenizeToStringArray(sockJsPath.substring(1), "/"); + if (pathSegments.length != 3) { + logger.warn("Expected \"/{server}/{session}/{transport}\" but got \"" + sockJsPath + "\""); + response.setStatusCode(HttpStatus.NOT_FOUND); + return; + } + + String serverId = pathSegments[0]; + String sessionId = pathSegments[1]; + String transport = pathSegments[2]; + + if (!validateRequest(serverId, sessionId, transport)) { + response.setStatusCode(HttpStatus.NOT_FOUND); + return; + } + + handleTransportRequest(request, response, sessionId, TransportType.fromValue(transport), handler); + } + finally { + response.flush(); + } + } + + /** + * Return the SockJS path or null if the path could not be determined. + */ + private String getSockJsPath(ServerHttpRequest request) { + + String path = request.getURI().getPath(); + + // SockJS prefix hints? + if (!this.sockJsPrefixes.isEmpty()) { + for (String prefix : this.sockJsPrefixes) { + int index = path.indexOf(prefix); + if (index != -1) { + this.sockJsPathCache.add(path.substring(0, index + prefix.length())); + return path.substring(index + prefix.length()); + } + } + } + + // SockJS info request? + if (path.endsWith("/info")) { + this.sockJsPathCache.add(path.substring(0, path.length() - 6)); + return "/info"; + } + + // Have we seen this prefix before (following the initial /info request)? + String match = null; + for (String sockJsPath : this.sockJsPathCache) { + if (path.startsWith(sockJsPath)) { + if ((match == null) || (match.length() < sockJsPath.length())) { + match = sockJsPath; + } + } + } + if (match != null) { + return path.substring(match.length()); + } + + // SockJS greeting? + String pathNoSlash = path.endsWith("/") ? path.substring(0, path.length() - 1) : path; + String lastSegment = pathNoSlash.substring(pathNoSlash.lastIndexOf('/') + 1); + + if ((TransportType.fromValue(lastSegment) == null) && !lastSegment.startsWith("iframe")) { + this.sockJsPathCache.add(path); + return ""; + } + + return null; + } + + protected abstract void handleRawWebSocketRequest(ServerHttpRequest request, + ServerHttpResponse response, WebSocketHandler webSocketHandler) throws IOException; + + protected abstract void handleTransportRequest(ServerHttpRequest request, ServerHttpResponse response, + String sessionId, TransportType transportType, WebSocketHandler webSocketHandler) + throws IOException, TransportErrorException; + + + protected boolean validateRequest(String serverId, String sessionId, String transport) { + + if (!StringUtils.hasText(serverId) || !StringUtils.hasText(sessionId) || !StringUtils.hasText(transport)) { + logger.warn("Empty server, session, or transport value"); + return false; + } + + // Server and session id's must not contain "." + if (serverId.contains(".") || sessionId.contains(".")) { + logger.warn("Server or session contain a \".\""); + return false; + } + + if (!isWebSocketEnabled() && transport.equals(TransportType.WEBSOCKET.value())) { + logger.warn("Websocket transport is disabled"); + return false; + } + + return true; + } + + protected void addCorsHeaders(ServerHttpRequest request, ServerHttpResponse response, HttpMethod... httpMethods) { + + String origin = request.getHeaders().getFirst("origin"); + origin = ((origin == null) || origin.equals("null")) ? "*" : origin; + + response.getHeaders().add("Access-Control-Allow-Origin", origin); + response.getHeaders().add("Access-Control-Allow-Credentials", "true"); + + List accessControllerHeaders = request.getHeaders().get("Access-Control-Request-Headers"); + if (accessControllerHeaders != null) { + for (String header : accessControllerHeaders) { + response.getHeaders().add("Access-Control-Allow-Headers", header); + } + } + + if (!ObjectUtils.isEmpty(httpMethods)) { + response.getHeaders().add("Access-Control-Allow-Methods", StringUtils.arrayToDelimitedString(httpMethods, ", ")); + response.getHeaders().add("Access-Control-Max-Age", String.valueOf(ONE_YEAR)); + } + } + + protected void addCacheHeaders(ServerHttpResponse response) { + response.getHeaders().setCacheControl("public, max-age=" + ONE_YEAR); + response.getHeaders().setExpires(new Date().getTime() + ONE_YEAR * 1000); + } + + protected void addNoCacheHeaders(ServerHttpResponse response) { + response.getHeaders().setCacheControl("no-store, no-cache, must-revalidate, max-age=0"); + } + + protected void sendMethodNotAllowed(ServerHttpResponse response, List httpMethods) throws IOException { + logger.debug("Sending Method Not Allowed (405)"); + response.setStatusCode(HttpStatus.METHOD_NOT_ALLOWED); + response.getHeaders().setAllow(new HashSet(httpMethods)); + } + + + private interface SockJsRequestHandler { + + void handle(ServerHttpRequest request, ServerHttpResponse response) throws IOException; + } + + private static final Random random = new Random(); + + private final SockJsRequestHandler infoHandler = new SockJsRequestHandler() { + + private static final String INFO_CONTENT = + "{\"entropy\":%s,\"origins\":[\"*:*\"],\"cookie_needed\":%s,\"websocket\":%s}"; + + public void handle(ServerHttpRequest request, ServerHttpResponse response) throws IOException { + + if (HttpMethod.GET.equals(request.getMethod())) { + + response.getHeaders().setContentType(new MediaType("application", "json", Charset.forName("UTF-8"))); + + addCorsHeaders(request, response); + addNoCacheHeaders(response); + + String content = String.format(INFO_CONTENT, random.nextInt(), isJsessionIdCookieRequired(), isWebSocketEnabled()); + response.getBody().write(content.getBytes()); + } + else if (HttpMethod.OPTIONS.equals(request.getMethod())) { + + response.setStatusCode(HttpStatus.NO_CONTENT); + + addCorsHeaders(request, response, HttpMethod.OPTIONS, HttpMethod.GET); + addCacheHeaders(response); + } + else { + sendMethodNotAllowed(response, Arrays.asList(HttpMethod.OPTIONS, HttpMethod.GET)); + } + } + }; + + private final SockJsRequestHandler iframeHandler = new SockJsRequestHandler() { + + private static final String IFRAME_CONTENT = + "\n" + + "\n" + + "\n" + + " \n" + + " \n" + + " \n" + + " \n" + + "\n" + + "\n" + + "

Don't panic!

\n" + + "

This is a SockJS hidden iframe. It's used for cross domain magic.

\n" + + "\n" + + ""; + + public void handle(ServerHttpRequest request, ServerHttpResponse response) throws IOException { + + if (!HttpMethod.GET.equals(request.getMethod())) { + sendMethodNotAllowed(response, Arrays.asList(HttpMethod.GET)); + return; + } + + String content = String.format(IFRAME_CONTENT, getSockJsClientLibraryUrl()); + byte[] contentBytes = content.getBytes(Charset.forName("UTF-8")); + StringBuilder builder = new StringBuilder("\"0"); + DigestUtils.appendMd5DigestAsHex(contentBytes, builder); + builder.append('"'); + String etagValue = builder.toString(); + + List ifNoneMatch = request.getHeaders().getIfNoneMatch(); + if (!CollectionUtils.isEmpty(ifNoneMatch) && ifNoneMatch.get(0).equals(etagValue)) { + response.setStatusCode(HttpStatus.NOT_MODIFIED); + return; + } + + response.getHeaders().setContentType(new MediaType("text", "html", Charset.forName("UTF-8"))); + response.getHeaders().setContentLength(contentBytes.length); + + addCacheHeaders(response); + response.getHeaders().setETag(etagValue); + response.getBody().write(contentBytes); + } + }; + + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/AbstractSockJsSession.java b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/AbstractSockJsSession.java new file mode 100644 index 0000000000..eb461db072 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/AbstractSockJsSession.java @@ -0,0 +1,258 @@ +/* + * 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.sockjs; + +import java.io.IOException; +import java.net.URI; +import java.security.Principal; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.util.Assert; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.adapter.ConfigurableWebSocketSession; + + +/** + * TODO + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +public abstract class AbstractSockJsSession implements ConfigurableWebSocketSession { + + protected final Log logger = LogFactory.getLog(getClass()); + + + private final String id; + + private URI uri; + + private String remoteHostName; + + private String remoteAddress; + + private Principal principal; + + private WebSocketHandler handler; + + private State state = State.NEW; + + private long timeCreated = System.currentTimeMillis(); + + private long timeLastActive = System.currentTimeMillis(); + + + /** + * @param sessionId + * @param webSocketHandler the recipient of SockJS messages + */ + public AbstractSockJsSession(String sessionId, WebSocketHandler webSocketHandler) { + Assert.notNull(sessionId, "sessionId is required"); + Assert.notNull(webSocketHandler, "webSocketHandler is required"); + this.id = sessionId; + this.handler = webSocketHandler; + } + + public String getId() { + return this.id; + } + + @Override + public URI getUri() { + return this.uri; + } + + @Override + public void setUri(URI uri) { + this.uri = uri; + } + + @Override + public boolean isSecure() { + return "wss".equals(this.uri.getSchemeSpecificPart()); + } + + public String getRemoteHostName() { + return this.remoteHostName; + } + + public void setRemoteHostName(String remoteHostName) { + this.remoteHostName = remoteHostName; + } + + public String getRemoteAddress() { + return this.remoteAddress; + } + + public void setRemoteAddress(String remoteAddress) { + this.remoteAddress = remoteAddress; + } + + public Principal getPrincipal() { + return this.principal; + } + + public void setPrincipal(Principal principal) { + this.principal = principal; + } + + public boolean isNew() { + return State.NEW.equals(this.state); + } + + public boolean isOpen() { + return State.OPEN.equals(this.state); + } + + public boolean isClosed() { + return State.CLOSED.equals(this.state); + } + + /** + * Polling and Streaming sessions periodically close the current HTTP request and + * wait for the next request to come through. During this "downtime" the session is + * still open but inactive and unable to send messages and therefore has to buffer + * them temporarily. A WebSocket session by contrast is stateful and remain active + * until closed. + */ + public abstract boolean isActive(); + + /** + * Return the time since the session was last active, or otherwise if the + * session is new, the time since the session was created. + */ + public long getTimeSinceLastActive() { + if (isNew()) { + return (System.currentTimeMillis() - this.timeCreated); + } + else { + return isActive() ? 0 : System.currentTimeMillis() - this.timeLastActive; + } + } + + /** + * Should be invoked whenever the session becomes inactive. + */ + protected void updateLastActiveTime() { + this.timeLastActive = System.currentTimeMillis(); + } + + public void delegateConnectionEstablished() throws Exception { + this.state = State.OPEN; + this.handler.afterConnectionEstablished(this); + } + + /** + * Close due to error arising from SockJS transport handling. + */ + protected void tryCloseWithSockJsTransportError(Throwable ex, CloseStatus closeStatus) { + logger.error("Closing due to transport error for " + this, ex); + try { + delegateError(ex); + } + catch (Throwable delegateEx) { + logger.error("Unhandled error for " + this, delegateEx); + try { + close(closeStatus); + } + catch (Throwable closeEx) { + logger.error("Unhandled error for " + this, closeEx); + } + } + } + + public void delegateMessages(String[] messages) throws Exception { + for (String message : messages) { + this.handler.handleMessage(this, new TextMessage(message)); + } + } + + public void delegateError(Throwable ex) throws Exception { + this.handler.handleTransportError(this, ex); + } + + /** + * Invoked in reaction to the underlying connection being closed by the remote side + * (or the WebSocket container) in order to perform cleanup and notify the + * {@link TextMessageHandler}. This is in contrast to {@link #close()} that pro-actively + * closes the connection. + */ + public final void delegateConnectionClosed(CloseStatus status) throws Exception { + if (!isClosed()) { + if (logger.isDebugEnabled()) { + logger.debug(this + " was closed, " + status); + } + try { + connectionClosedInternal(status); + } + finally { + this.state = State.CLOSED; + this.handler.afterConnectionClosed(this, status); + } + } + } + + protected void connectionClosedInternal(CloseStatus status) { + } + + /** + * {@inheritDoc} + *

Performs cleanup and notifies the {@link SockJsHandler}. + */ + public final void close() throws IOException { + close(new CloseStatus(3000, "Go away!")); + } + + /** + * {@inheritDoc} + *

Performs cleanup and notifies the {@link SockJsHandler}. + */ + public final void close(CloseStatus status) throws IOException { + if (isOpen()) { + if (logger.isDebugEnabled()) { + logger.debug("Closing " + this + ", " + status); + } + try { + closeInternal(status); + } + finally { + this.state = State.CLOSED; + try { + this.handler.afterConnectionClosed(this, status); + } + catch (Throwable t) { + logger.error("Unhandled error for " + this, t); + } + } + } + } + + protected abstract void closeInternal(CloseStatus status) throws IOException; + + + @Override + public String toString() { + return "SockJS session id=" + this.id; + } + + + private enum State { NEW, OPEN, CLOSED } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/ConfigurableTransportHandler.java b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/ConfigurableTransportHandler.java new file mode 100644 index 0000000000..5e10c7bf01 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/ConfigurableTransportHandler.java @@ -0,0 +1,27 @@ +/* + * 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.sockjs; + +/** + * @author Rossen Stoyanchev + * @since 4.0 + */ +public interface ConfigurableTransportHandler extends TransportHandler { + + void setSockJsConfiguration(SockJsConfiguration sockJsConfig); + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/SockJsConfiguration.java b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/SockJsConfiguration.java new file mode 100644 index 0000000000..3fbf767130 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/SockJsConfiguration.java @@ -0,0 +1,54 @@ +/* + * 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.sockjs; + +import org.springframework.scheduling.TaskScheduler; + +/** + * @author Rossen Stoyanchev + * @since 4.0 + */ +public interface SockJsConfiguration { + + /** + * Streaming transports save responses on the client side and don't free + * memory used by delivered messages. Such transports need to recycle the + * connection once in a while. This property sets a minimum number of bytes + * that can be send over a single HTTP streaming request before it will be + * closed. After that client will open a new request. Setting this value to + * one effectively disables streaming and will make streaming transports to + * behave like polling transports. + *

+ * The default value is 128K (i.e. 128 * 1024). + */ + public int getStreamBytesLimit(); + + /** + * The amount of time in milliseconds when the server has not sent any + * messages and after which the server should send a heartbeat frame to the + * client in order to keep the connection from breaking. + *

+ * The default value is 25,000 (25 seconds). + */ + public long getHeartbeatTime(); + + /** + * A scheduler instance to use for scheduling heart-beat messages. + */ + public TaskScheduler getTaskScheduler(); + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/SockJsFrame.java b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/SockJsFrame.java new file mode 100644 index 0000000000..c1a52ef37d --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/SockJsFrame.java @@ -0,0 +1,174 @@ +/* + * 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.sockjs; + +import java.nio.charset.Charset; + +import org.springframework.util.Assert; + +import com.fasterxml.jackson.core.io.JsonStringEncoder; + +/** + * @author Rossen Stoyanchev + * @since 4.0 + */ +public class SockJsFrame { + + private static final SockJsFrame OPEN_FRAME = new SockJsFrame("o"); + + private static final SockJsFrame HEARTBEAT_FRAME = new SockJsFrame("h"); + + private static final SockJsFrame CLOSE_GO_AWAY_FRAME = closeFrame(3000, "Go away!"); + + private static final SockJsFrame CLOSE_ANOTHER_CONNECTION_OPEN = closeFrame(2010, "Another connection still open"); + + + private final String content; + + + private SockJsFrame(String content) { + this.content = content; + } + + + public static SockJsFrame openFrame() { + return OPEN_FRAME; + } + + public static SockJsFrame heartbeatFrame() { + return HEARTBEAT_FRAME; + } + + public static SockJsFrame messageFrame(String... messages) { + return new MessageFrame(messages); + } + + public static SockJsFrame closeFrameGoAway() { + return CLOSE_GO_AWAY_FRAME; + } + + public static SockJsFrame closeFrameAnotherConnectionOpen() { + return CLOSE_ANOTHER_CONNECTION_OPEN; + } + + public static SockJsFrame closeFrame(int code, String reason) { + return new SockJsFrame("c[" + code + ",\"" + reason + "\"]"); + } + + + public String getContent() { + return this.content; + } + + public byte[] getContentBytes() { + return this.content.getBytes(Charset.forName("UTF-8")); + } + + /** + * See "JSON Unicode Encoding" section of SockJS protocol. + */ + public static String escapeCharacters(char[] characters) { + StringBuilder result = new StringBuilder(); + for (char c : characters) { + if (isSockJsEscapeCharacter(c)) { + result.append('\\').append('u'); + String hex = Integer.toHexString(c).toLowerCase(); + for (int i = 0; i < (4 - hex.length()); i++) { + result.append('0'); + } + result.append(hex); + } + else { + result.append(c); + } + } + return result.toString(); + } + + // See `escapable_by_server` var in SockJS protocol (under "JSON Unicode Encoding") + + private static boolean isSockJsEscapeCharacter(char ch) { + return (ch >= '\u0000' && ch <= '\u001F') || (ch >= '\u200C' && ch <= '\u200F') + || (ch >= '\u2028' && ch <= '\u202F') || (ch >= '\u2060' && ch <= '\u206F') + || (ch >= '\uFFF0' && ch <= '\uFFFF') || (ch >= '\uD800' && ch <= '\uDFFF'); + } + + public String toString() { + String result = this.content; + if (result.length() > 80) { + result = result.substring(0, 80) + "...(truncated)"; + } + return "SockJsFrame content='" + result.replace("\n", "\\n").replace("\r", "\\r") + "'"; + } + + + private static class MessageFrame extends SockJsFrame { + + public MessageFrame(String... messages) { + super(prepareContent(messages)); + } + + public static String prepareContent(String... messages) { + Assert.notNull(messages, "messages required"); + StringBuilder sb = new StringBuilder(); + sb.append("a["); + for (int i=0; i < messages.length; i++) { + sb.append('"'); + // TODO: dependency on Jackson + char[] quotedChars = JsonStringEncoder.getInstance().quoteAsString(messages[i]); + sb.append(escapeCharacters(quotedChars)); + sb.append('"'); + if (i < messages.length - 1) { + sb.append(','); + } + } + sb.append(']'); + return sb.toString(); + } + } + + public interface FrameFormat { + + SockJsFrame format(SockJsFrame frame); + } + + public static class DefaultFrameFormat implements FrameFormat { + + private final String format; + + public DefaultFrameFormat(String format) { + Assert.notNull(format, "format is required"); + this.format = format; + } + + /** + * + * @param format a String with a single %s formatting character where the + * frame content is to be inserted; e.g. "data: %s\r\n\r\n" + * @return new SockJsFrame instance with the formatted content + */ + public SockJsFrame format(SockJsFrame frame) { + String content = String.format(this.format, preProcessContent(frame.getContent())); + return new SockJsFrame(content); + } + + protected String preProcessContent(String content) { + return content; + } + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/SockJsRuntimeException.java b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/SockJsRuntimeException.java new file mode 100644 index 0000000000..3803bb9634 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/SockJsRuntimeException.java @@ -0,0 +1,37 @@ +/* + * 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.sockjs; + +import org.springframework.core.NestedRuntimeException; + +/** + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +@SuppressWarnings("serial") +public class SockJsRuntimeException extends NestedRuntimeException { + + public SockJsRuntimeException(String msg) { + super(msg); + } + + public SockJsRuntimeException(String msg, Throwable cause) { + super(msg, cause); + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/SockJsService.java b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/SockJsService.java new file mode 100644 index 0000000000..45e34d4c69 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/SockJsService.java @@ -0,0 +1,36 @@ +/* + * 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.sockjs; + +import java.io.IOException; + +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.web.socket.WebSocketHandler; + +/** + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +public interface SockJsService { + + + void handleRequest(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler handler) + throws IOException, TransportErrorException; + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/SockJsSessionFactory.java b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/SockJsSessionFactory.java new file mode 100644 index 0000000000..5f78084550 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/SockJsSessionFactory.java @@ -0,0 +1,38 @@ +/* + * 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.sockjs; + +import org.springframework.web.socket.WebSocketHandler; + +/** + * A factory for creating a SockJS session. + * + * @param The type of session being created + * @author Rossen Stoyanchev + * @since 4.0 + */ +public interface SockJsSessionFactory { + + /** + * Create a new SockJS session. + * @param sessionId the ID of the session + * @param webSocketHandler the underlying {@link WebSocketHandler} + * @return a new non-null session + */ + AbstractSockJsSession createSession(String sessionId, WebSocketHandler webSocketHandler); + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/TransportErrorException.java b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/TransportErrorException.java new file mode 100644 index 0000000000..9b35daaa4d --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/TransportErrorException.java @@ -0,0 +1,55 @@ +/* + * 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.sockjs; + +import org.springframework.core.NestedRuntimeException; +import org.springframework.web.socket.WebSocketHandler; + + +/** + * Raised when a TransportHandler fails during request processing. + * + *

If the underlying exception occurs while sending messages to the client, + * the session will have been closed and the {@link WebSocketHandler} notified. + * + *

If the underlying exception occurs while processing an incoming HTTP request + * including posted messages, the session will remain open. Only the incoming + * request is rejected. + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +@SuppressWarnings("serial") +public class TransportErrorException extends NestedRuntimeException { + + private final String sockJsSessionId; + + public TransportErrorException(String msg, Throwable cause, String sockJsSessionId) { + super(msg, cause); + this.sockJsSessionId = sockJsSessionId; + } + + public String getSockJsSessionId() { + return sockJsSessionId; + } + + @Override + public String getMessage() { + return "Transport error for SockJS session id=" + this.sockJsSessionId + ", " + super.getMessage(); + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/TransportHandler.java b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/TransportHandler.java new file mode 100644 index 0000000000..82e1a03bb8 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/TransportHandler.java @@ -0,0 +1,34 @@ +/* + * 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.sockjs; + +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.web.socket.WebSocketHandler; + +/** + * @author Rossen Stoyanchev + * @since 4.0 + */ +public interface TransportHandler { + + TransportType getTransportType(); + + void handleRequest(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler handler, AbstractSockJsSession session) throws TransportErrorException; + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/TransportType.java b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/TransportType.java new file mode 100644 index 0000000000..1c2c5814d7 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/TransportType.java @@ -0,0 +1,103 @@ +/* + * 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.sockjs; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.http.HttpMethod; + +/** + * @author Rossen Stoyanchev + * @since 4.0 + */ +public enum TransportType { + + WEBSOCKET("websocket", HttpMethod.GET), + + XHR("xhr", HttpMethod.POST, "cors", "jsessionid", "no_cache"), + + XHR_SEND("xhr_send", HttpMethod.POST, "cors", "jsessionid", "no_cache"), + + JSONP("jsonp", HttpMethod.GET, "jsessionid", "no_cache"), + + JSONP_SEND("jsonp_send", HttpMethod.POST, "jsessionid", "no_cache"), + + XHR_STREAMING("xhr_streaming", HttpMethod.POST, "cors", "jsessionid", "no_cache"), + + EVENT_SOURCE("eventsource", HttpMethod.GET, "jsessionid", "no_cache"), + + HTML_FILE("htmlfile", HttpMethod.GET, "jsessionid", "no_cache"); + + + private final String value; + + private final HttpMethod httpMethod; + + private final List headerHints; + + private static final Map transportTypes = new HashMap(); + + static { + for (TransportType type : values()) { + transportTypes.put(type.value, type); + } + } + + + private TransportType(String value, HttpMethod httpMethod, String... headerHints) { + this.value = value; + this.httpMethod = httpMethod; + this.headerHints = Arrays.asList(headerHints); + } + + + public String value() { + return this.value; + } + + /** + * The HTTP method for this transport. + */ + public HttpMethod getHttpMethod() { + return this.httpMethod; + } + + public boolean setsNoCache() { + return this.headerHints.contains("no_cache"); + } + + public boolean supportsCors() { + return this.headerHints.contains("cors"); + } + + public boolean setsJsessionId() { + return this.headerHints.contains("jsessionid"); + } + + public static TransportType fromValue(String value) { + return transportTypes.get(value); + } + + @Override + public String toString() { + return this.value; + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/package-info.java b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/package-info.java new file mode 100644 index 0000000000..d523c864b3 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/package-info.java @@ -0,0 +1,21 @@ +/* + * 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. + */ + +/** + * Common abstractions for the SockJS protocol. + */ +package org.springframework.web.socket.sockjs; + diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/support/DefaultSockJsService.java b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/support/DefaultSockJsService.java new file mode 100644 index 0000000000..6f8e497678 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/support/DefaultSockJsService.java @@ -0,0 +1,274 @@ +/* + * 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.sockjs.support; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledFuture; + +import org.springframework.http.Cookie; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.util.CollectionUtils; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.DefaultHandshakeHandler; +import org.springframework.web.socket.server.HandshakeHandler; +import org.springframework.web.socket.server.support.ServerWebSocketSessionInitializer; +import org.springframework.web.socket.sockjs.AbstractSockJsService; +import org.springframework.web.socket.sockjs.AbstractSockJsSession; +import org.springframework.web.socket.sockjs.ConfigurableTransportHandler; +import org.springframework.web.socket.sockjs.SockJsService; +import org.springframework.web.socket.sockjs.SockJsSessionFactory; +import org.springframework.web.socket.sockjs.TransportErrorException; +import org.springframework.web.socket.sockjs.TransportHandler; +import org.springframework.web.socket.sockjs.TransportType; +import org.springframework.web.socket.sockjs.transport.EventSourceTransportHandler; +import org.springframework.web.socket.sockjs.transport.HtmlFileTransportHandler; +import org.springframework.web.socket.sockjs.transport.JsonpPollingTransportHandler; +import org.springframework.web.socket.sockjs.transport.JsonpTransportHandler; +import org.springframework.web.socket.sockjs.transport.WebSocketTransportHandler; +import org.springframework.web.socket.sockjs.transport.XhrPollingTransportHandler; +import org.springframework.web.socket.sockjs.transport.XhrStreamingTransportHandler; +import org.springframework.web.socket.sockjs.transport.XhrTransportHandler; + + +/** + * A default implementation of {@link SockJsService} adding support for transport handling + * and session management. + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +public class DefaultSockJsService extends AbstractSockJsService { + + private final Map transportHandlers = new HashMap(); + + private final Map sessions = new ConcurrentHashMap(); + + private final ServerWebSocketSessionInitializer sessionInitializer = new ServerWebSocketSessionInitializer(); + + private ScheduledFuture sessionCleanupTask; + + + /** + * Create an instance with default {@link TransportHandler transport handler} types. + * + * @param taskScheduler a task scheduler for heart-beat messages and removing + * timed-out sessions; the provided TaskScheduler should be declared as a + * Spring bean to ensure it is initialized at start up and shut down when the + * application stops. + */ + public DefaultSockJsService(TaskScheduler taskScheduler) { + this(taskScheduler, null); + } + + /** + * Create an instance by overriding or replacing completely the default + * {@link TransportHandler transport handler} types. + * + * @param taskScheduler a task scheduler for heart-beat messages and removing + * timed-out sessions; the provided TaskScheduler should be declared as a + * Spring bean to ensure it is initialized at start up and shut down when the + * application stops. + * @param transportHandlers the transport handlers to use (replaces the default ones); + * can be {@code null}. + * @param transportHandlerOverrides zero or more overrides to the default transport + * handler types. + */ + public DefaultSockJsService(TaskScheduler taskScheduler, Set transportHandlers, + TransportHandler... transportHandlerOverrides) { + + super(taskScheduler); + + transportHandlers = CollectionUtils.isEmpty(transportHandlers) ? getDefaultTransportHandlers() : transportHandlers; + addTransportHandlers(transportHandlers); + addTransportHandlers(Arrays.asList(transportHandlerOverrides)); + } + + protected final Set getDefaultTransportHandlers() { + Set result = new HashSet(); + result.add(new XhrPollingTransportHandler()); + result.add(new XhrTransportHandler()); + result.add(new JsonpPollingTransportHandler()); + result.add(new JsonpTransportHandler()); + result.add(new XhrStreamingTransportHandler()); + result.add(new EventSourceTransportHandler()); + result.add(new HtmlFileTransportHandler()); + try { + result.add(new WebSocketTransportHandler(new DefaultHandshakeHandler())); + } + catch (Exception ex) { + if (logger.isWarnEnabled()) { + logger.warn("Failed to add default WebSocketTransportHandler: " + ex.getMessage()); + } + } + return result; + } + + protected void addTransportHandlers(Collection handlers) { + for (TransportHandler handler : handlers) { + if (handler instanceof ConfigurableTransportHandler) { + ((ConfigurableTransportHandler) handler).setSockJsConfiguration(this); + } + this.transportHandlers.put(handler.getTransportType(), handler); + } + } + + public Map getTransportHandlers() { + return Collections.unmodifiableMap(this.transportHandlers); + } + + @Override + protected void handleRawWebSocketRequest(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler webSocketHandler) throws IOException { + + if (isWebSocketEnabled()) { + TransportHandler transportHandler = this.transportHandlers.get(TransportType.WEBSOCKET); + if (transportHandler != null) { + if (transportHandler instanceof HandshakeHandler) { + ((HandshakeHandler) transportHandler).doHandshake(request, response, webSocketHandler); + return; + } + } + logger.warn("No handler for raw WebSocket messages"); + } + response.setStatusCode(HttpStatus.NOT_FOUND); + } + + @Override + protected void handleTransportRequest(ServerHttpRequest request, ServerHttpResponse response, + String sessionId, TransportType transportType, WebSocketHandler webSocketHandler) + throws IOException, TransportErrorException { + + TransportHandler transportHandler = this.transportHandlers.get(transportType); + + if (transportHandler == null) { + logger.debug("Transport handler not found"); + response.setStatusCode(HttpStatus.NOT_FOUND); + return; + } + + HttpMethod supportedMethod = transportType.getHttpMethod(); + if (!supportedMethod.equals(request.getMethod())) { + if (HttpMethod.OPTIONS.equals(request.getMethod()) && transportType.supportsCors()) { + response.setStatusCode(HttpStatus.NO_CONTENT); + addCorsHeaders(request, response, HttpMethod.OPTIONS, supportedMethod); + addCacheHeaders(response); + } + else { + List supportedMethods = Arrays.asList(supportedMethod); + if (transportType.supportsCors()) { + supportedMethods.add(HttpMethod.OPTIONS); + } + sendMethodNotAllowed(response, supportedMethods); + } + return; + } + + AbstractSockJsSession session = getSockJsSession(sessionId, webSocketHandler, + transportHandler, request, response); + + if (session != null) { + if (transportType.setsNoCache()) { + addNoCacheHeaders(response); + } + + if (transportType.setsJsessionId() && isJsessionIdCookieRequired()) { + Cookie cookie = request.getCookies().getCookie("JSESSIONID"); + String jsid = (cookie != null) ? cookie.getValue() : "dummy"; + // TODO: bypass use of Cookie object (causes Jetty to set Expires header) + response.getHeaders().set("Set-Cookie", "JSESSIONID=" + jsid + ";path=/"); + } + + if (transportType.supportsCors()) { + addCorsHeaders(request, response); + } + } + + transportHandler.handleRequest(request, response, webSocketHandler, session); + } + + protected AbstractSockJsSession getSockJsSession(String sessionId, WebSocketHandler handler, + TransportHandler transportHandler, ServerHttpRequest request, ServerHttpResponse response) { + + AbstractSockJsSession session = this.sessions.get(sessionId); + if (session != null) { + return session; + } + + if (transportHandler instanceof SockJsSessionFactory) { + SockJsSessionFactory sessionFactory = (SockJsSessionFactory) transportHandler; + + synchronized (this.sessions) { + session = this.sessions.get(sessionId); + if (session != null) { + return session; + } + if (this.sessionCleanupTask == null) { + scheduleSessionTask(); + } + logger.debug("Creating new session with session id \"" + sessionId + "\""); + session = sessionFactory.createSession(sessionId, handler); + this.sessionInitializer.initialize(request, response, session); + this.sessions.put(sessionId, session); + return session; + } + } + + return null; + } + + private void scheduleSessionTask() { + this.sessionCleanupTask = getTaskScheduler().scheduleAtFixedRate(new Runnable() { + public void run() { + try { + int count = sessions.size(); + if (logger.isTraceEnabled() && (count != 0)) { + logger.trace("Checking " + count + " session(s) for timeouts [" + getName() + "]"); + } + for (AbstractSockJsSession session : sessions.values()) { + if (session.getTimeSinceLastActive() > getDisconnectDelay()) { + if (logger.isTraceEnabled()) { + logger.trace("Removing " + session + " for [" + getName() + "]"); + } + session.close(); + sessions.remove(session.getId()); + } + } + if (logger.isTraceEnabled() && (count != 0)) { + logger.trace(sessions.size() + " remaining session(s) [" + getName() + "]"); + } + } + catch (Throwable t) { + logger.error("Failed to complete session timeout checks for [" + getName() + "]", t); + } + } + }, getDisconnectDelay()); + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/support/SockJsHttpRequestHandler.java b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/support/SockJsHttpRequestHandler.java new file mode 100644 index 0000000000..8a335a76d2 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/support/SockJsHttpRequestHandler.java @@ -0,0 +1,78 @@ +/* + * 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.sockjs.support; + +import java.io.IOException; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.http.server.AsyncServletServerHttpRequest; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpResponse; +import org.springframework.util.Assert; +import org.springframework.web.HttpRequestHandler; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.sockjs.SockJsService; +import org.springframework.web.socket.support.ExceptionWebSocketHandlerDecorator; +import org.springframework.web.socket.support.LoggingWebSocketHandlerDecorator; + +/** + * @author Rossen Stoyanchev + * @since 4.0 + */ +public class SockJsHttpRequestHandler implements HttpRequestHandler { + + private final SockJsService sockJsService; + + private final WebSocketHandler webSocketHandler; + + + /** + * Class constructor with {@link SockJsHandler} instance ... + */ + public SockJsHttpRequestHandler(SockJsService sockJsService, WebSocketHandler webSocketHandler) { + Assert.notNull(sockJsService, "sockJsService is required"); + Assert.notNull(webSocketHandler, "webSocketHandler is required"); + this.sockJsService = sockJsService; + this.webSocketHandler = decorateWebSocketHandler(webSocketHandler); + } + + /** + * Decorate the WebSocketHandler provided to the class constructor. + *

+ * By default {@link ExceptionWebSocketHandlerDecorator} and + * {@link LoggingWebSocketHandlerDecorator} are applied are added. + */ + protected WebSocketHandler decorateWebSocketHandler(WebSocketHandler handler) { + handler = new ExceptionWebSocketHandlerDecorator(handler); + return new LoggingWebSocketHandlerDecorator(handler); + } + + @Override + public void handleRequest(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + + ServerHttpRequest httpRequest = new AsyncServletServerHttpRequest(request, response); + ServerHttpResponse httpResponse = new ServletServerHttpResponse(response); + + this.sockJsService.handleRequest(httpRequest, httpResponse, this.webSocketHandler); + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/support/package-info.java b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/support/package-info.java new file mode 100644 index 0000000000..b5a32e8776 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/support/package-info.java @@ -0,0 +1,24 @@ +/* + * 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. + */ + +/** + * Server-side SockJS classes including a + * {@link org.springframework.web.socket.sockjs.support.DefaultSockJsService} implementation + * as well as a Spring MVC HandlerMapping mapping SockJS services to incoming requests. + * + */ +package org.springframework.web.socket.sockjs.support; + diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/AbstractHttpReceivingTransportHandler.java b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/AbstractHttpReceivingTransportHandler.java new file mode 100644 index 0000000000..237343c9ac --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/AbstractHttpReceivingTransportHandler.java @@ -0,0 +1,127 @@ +/* + * 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.sockjs.transport; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.Arrays; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.sockjs.AbstractSockJsSession; +import org.springframework.web.socket.sockjs.SockJsFrame; +import org.springframework.web.socket.sockjs.SockJsRuntimeException; +import org.springframework.web.socket.sockjs.TransportErrorException; +import org.springframework.web.socket.sockjs.TransportHandler; +import org.springframework.web.socket.support.ExceptionWebSocketHandlerDecorator; + +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * TODO + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +public abstract class AbstractHttpReceivingTransportHandler implements TransportHandler { + + protected final Log logger = LogFactory.getLog(this.getClass()); + + // TODO: the JSON library used must be configurable + private final ObjectMapper objectMapper = new ObjectMapper(); + + + public ObjectMapper getObjectMapper() { + return this.objectMapper; + } + + @Override + public final void handleRequest(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler webSocketHandler, AbstractSockJsSession session) + throws TransportErrorException { + + if (session == null) { + response.setStatusCode(HttpStatus.NOT_FOUND); + logger.warn("Session not found"); + return; + } + + handleRequestInternal(request, response, session); + } + + protected void handleRequestInternal(ServerHttpRequest request, ServerHttpResponse response, + AbstractSockJsSession session) throws TransportErrorException { + + String[] messages = null; + try { + messages = readMessages(request); + } + catch (JsonMappingException ex) { + logger.error("Failed to read message: ", ex); + sendInternalServerError(response, "Payload expected.", session.getId()); + return; + } + catch (IOException ex) { + logger.error("Failed to read message: ", ex); + sendInternalServerError(response, "Broken JSON encoding.", session.getId()); + return; + } + catch (Throwable t) { + logger.error("Failed to read message: ", t); + sendInternalServerError(response, "Failed to process messages", session.getId()); + return; + } + + if (logger.isTraceEnabled()) { + logger.trace("Received message(s): " + Arrays.asList(messages)); + } + + response.setStatusCode(getResponseStatus()); + response.getHeaders().setContentType(new MediaType("text", "plain", Charset.forName("UTF-8"))); + + try { + session.delegateMessages(messages); + } + catch (Throwable t) { + ExceptionWebSocketHandlerDecorator.tryCloseWithError(session, t, logger); + throw new SockJsRuntimeException("Unhandled WebSocketHandler error in " + this, t); + } + } + + protected void sendInternalServerError(ServerHttpResponse response, String error, + String sessionId) throws TransportErrorException { + + try { + response.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR); + response.getBody().write(error.getBytes("UTF-8")); + } + catch (Throwable t) { + throw new TransportErrorException("Failed to send error message to client", t, sessionId); + } + } + + protected abstract String[] readMessages(ServerHttpRequest request) throws IOException; + + protected abstract HttpStatus getResponseStatus(); + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/AbstractHttpSendingTransportHandler.java b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/AbstractHttpSendingTransportHandler.java new file mode 100644 index 0000000000..8c76b8c14e --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/AbstractHttpSendingTransportHandler.java @@ -0,0 +1,97 @@ +/* + * 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.sockjs.transport; + +import java.io.IOException; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.http.MediaType; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.sockjs.AbstractSockJsSession; +import org.springframework.web.socket.sockjs.ConfigurableTransportHandler; +import org.springframework.web.socket.sockjs.SockJsConfiguration; +import org.springframework.web.socket.sockjs.SockJsFrame; +import org.springframework.web.socket.sockjs.SockJsSessionFactory; +import org.springframework.web.socket.sockjs.TransportErrorException; +import org.springframework.web.socket.sockjs.SockJsFrame.FrameFormat; + +/** + * TODO + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +public abstract class AbstractHttpSendingTransportHandler + implements ConfigurableTransportHandler, SockJsSessionFactory { + + protected final Log logger = LogFactory.getLog(this.getClass()); + + private SockJsConfiguration sockJsConfig; + + + @Override + public void setSockJsConfiguration(SockJsConfiguration sockJsConfig) { + this.sockJsConfig = sockJsConfig; + } + + public SockJsConfiguration getSockJsConfig() { + return this.sockJsConfig; + } + + @Override + public final void handleRequest(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler webSocketHandler, AbstractSockJsSession session) + throws TransportErrorException { + + // Set content type before writing + response.getHeaders().setContentType(getContentType()); + + AbstractHttpServerSockJsSession httpServerSession = (AbstractHttpServerSockJsSession) session; + handleRequestInternal(request, response, httpServerSession); + } + + protected void handleRequestInternal(ServerHttpRequest request, ServerHttpResponse response, + AbstractHttpServerSockJsSession httpServerSession) throws TransportErrorException { + + if (httpServerSession.isNew()) { + logger.debug("Opening " + getTransportType() + " connection"); + httpServerSession.setInitialRequest(request, response, getFrameFormat(request)); + } + else if (!httpServerSession.isActive()) { + logger.debug("starting " + getTransportType() + " async request"); + httpServerSession.setLongPollingRequest(request, response, getFrameFormat(request)); + } + else { + try { + logger.debug("another " + getTransportType() + " connection still open: " + httpServerSession); + SockJsFrame closeFrame = SockJsFrame.closeFrameAnotherConnectionOpen(); + response.getBody().write(getFrameFormat(request).format(closeFrame).getContentBytes()); + } + catch (IOException e) { + throw new TransportErrorException("Failed to send SockJS close frame", e, httpServerSession.getId()); + } + } + } + + protected abstract MediaType getContentType(); + + protected abstract FrameFormat getFrameFormat(ServerHttpRequest request); + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/AbstractHttpServerSockJsSession.java b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/AbstractHttpServerSockJsSession.java new file mode 100644 index 0000000000..be38656c67 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/AbstractHttpServerSockJsSession.java @@ -0,0 +1,183 @@ +/* + * 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.sockjs.transport; + +import java.io.IOException; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; + +import org.springframework.http.server.AsyncServerHttpRequest; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.util.Assert; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.sockjs.AbstractServerSockJsSession; +import org.springframework.web.socket.sockjs.SockJsConfiguration; +import org.springframework.web.socket.sockjs.SockJsFrame; +import org.springframework.web.socket.sockjs.TransportErrorException; +import org.springframework.web.socket.sockjs.SockJsFrame.FrameFormat; +import org.springframework.web.socket.support.ExceptionWebSocketHandlerDecorator; + +/** + * An abstract base class for use with HTTP-based transports. + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +public abstract class AbstractHttpServerSockJsSession extends AbstractServerSockJsSession { + + private FrameFormat frameFormat; + + private final BlockingQueue messageCache = new ArrayBlockingQueue(100); + + private AsyncServerHttpRequest asyncRequest; + + private ServerHttpResponse response; + + + public AbstractHttpServerSockJsSession(String sessionId, SockJsConfiguration config, WebSocketHandler handler) { + super(sessionId, config, handler); + } + + public synchronized void setInitialRequest(ServerHttpRequest request, ServerHttpResponse response, + FrameFormat frameFormat) throws TransportErrorException { + + try { + udpateRequest(request, response, frameFormat); + writePrelude(); + writeFrame(SockJsFrame.openFrame()); + } + catch (Throwable t) { + tryCloseWithSockJsTransportError(t, null); + throw new TransportErrorException("Failed open SockJS session", t, getId()); + } + try { + delegateConnectionEstablished(); + } + catch (Throwable t) { + ExceptionWebSocketHandlerDecorator.tryCloseWithError(this, t, logger); + } + } + + protected void writePrelude() throws IOException { + } + + public synchronized void setLongPollingRequest(ServerHttpRequest request, ServerHttpResponse response, + FrameFormat frameFormat) throws TransportErrorException { + + try { + udpateRequest(request, response, frameFormat); + + if (isClosed()) { + logger.debug("connection already closed"); + try { + writeFrame(SockJsFrame.closeFrameGoAway()); + } + catch (IOException ex) { + throw new TransportErrorException("Failed to send SockJS close frame", ex, getId()); + } + return; + } + + this.asyncRequest.setTimeout(-1); + this.asyncRequest.startAsync(); + + scheduleHeartbeat(); + tryFlushCache(); + } + catch (Throwable t) { + tryCloseWithSockJsTransportError(t, null); + throw new TransportErrorException("Failed to start long running request and flush messages", t, getId()); + } + } + + private void udpateRequest(ServerHttpRequest request, ServerHttpResponse response, FrameFormat frameFormat) { + Assert.notNull(request, "expected request"); + Assert.notNull(response, "expected response"); + Assert.notNull(frameFormat, "expected frameFormat"); + Assert.isInstanceOf(AsyncServerHttpRequest.class, request, "Expected AsyncServerHttpRequest"); + this.asyncRequest = (AsyncServerHttpRequest) request; + this.response = response; + this.frameFormat = frameFormat; + } + + + public synchronized boolean isActive() { + return ((this.asyncRequest != null) && (!this.asyncRequest.isAsyncCompleted())); + } + + protected BlockingQueue getMessageCache() { + return this.messageCache; + } + + protected ServerHttpRequest getRequest() { + return this.asyncRequest; + } + + protected ServerHttpResponse getResponse() { + return this.response; + } + + protected final synchronized void sendMessageInternal(String message) throws IOException { + this.messageCache.add(message); + tryFlushCache(); + } + + private void tryFlushCache() throws IOException { + if (isActive() && !getMessageCache().isEmpty()) { + logger.trace("Flushing messages"); + flushCache(); + } + } + + /** + * Only called if the connection is currently active + */ + protected abstract void flushCache() throws IOException; + + @Override + protected void disconnect(CloseStatus status) { + resetRequest(); + } + + protected synchronized void resetRequest() { + updateLastActiveTime(); + if (isActive() && this.asyncRequest.isAsyncStarted()) { + try { + logger.debug("Completing async request"); + this.asyncRequest.completeAsync(); + } + catch (Throwable ex) { + logger.error("Failed to complete async request: " + ex.getMessage()); + } + } + this.asyncRequest = null; + this.response = null; + } + + protected synchronized void writeFrameInternal(SockJsFrame frame) throws IOException { + if (isActive()) { + frame = this.frameFormat.format(frame); + if (logger.isTraceEnabled()) { + logger.trace("Writing " + frame); + } + this.response.getBody().write(frame.getContentBytes()); + } + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/EventSourceTransportHandler.java b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/EventSourceTransportHandler.java new file mode 100644 index 0000000000..f469628c05 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/EventSourceTransportHandler.java @@ -0,0 +1,66 @@ +/* + * 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.sockjs.transport; + +import java.io.IOException; +import java.nio.charset.Charset; + +import org.springframework.http.MediaType; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.util.Assert; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.sockjs.TransportType; +import org.springframework.web.socket.sockjs.SockJsFrame.DefaultFrameFormat; +import org.springframework.web.socket.sockjs.SockJsFrame.FrameFormat; + +/** + * TODO + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +public class EventSourceTransportHandler extends AbstractHttpSendingTransportHandler { + + @Override + public TransportType getTransportType() { + return TransportType.EVENT_SOURCE; + } + + @Override + protected MediaType getContentType() { + return new MediaType("text", "event-stream", Charset.forName("UTF-8")); + } + + @Override + public StreamingServerSockJsSession createSession(String sessionId, WebSocketHandler handler) { + Assert.notNull(getSockJsConfig(), "This transport requires SockJsConfiguration"); + return new StreamingServerSockJsSession(sessionId, getSockJsConfig(), handler) { + @Override + protected void writePrelude() throws IOException { + getResponse().getBody().write('\r'); + getResponse().getBody().write('\n'); + getResponse().flush(); + } + }; + } + + @Override + protected FrameFormat getFrameFormat(ServerHttpRequest request) { + return new DefaultFrameFormat("data: %s\r\n\r\n"); + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/HtmlFileTransportHandler.java b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/HtmlFileTransportHandler.java new file mode 100644 index 0000000000..acf2c877fc --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/HtmlFileTransportHandler.java @@ -0,0 +1,129 @@ +/* + * 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.sockjs.transport; + +import java.io.IOException; +import java.nio.charset.Charset; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.sockjs.TransportErrorException; +import org.springframework.web.socket.sockjs.TransportType; +import org.springframework.web.socket.sockjs.SockJsFrame.DefaultFrameFormat; +import org.springframework.web.socket.sockjs.SockJsFrame.FrameFormat; +import org.springframework.web.util.JavaScriptUtils; + +/** + * TODO + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +public class HtmlFileTransportHandler extends AbstractHttpSendingTransportHandler { + + private static final String PARTIAL_HTML_CONTENT; + + static { + StringBuilder sb = new StringBuilder( + "\n" + + "\n" + + " \n" + + " \n" + + "

Don't panic!

\n" + + " " + ); + + // Safari needs at least 1024 bytes to parse the website. + // http://code.google.com/p/browsersec/wiki/Part2#Survey_of_content_sniffing_behaviors + int spaces = 1024 - sb.length(); + for (int i=0; i < spaces; i++) { + sb.append(' '); + } + + PARTIAL_HTML_CONTENT = sb.toString(); + } + + + @Override + public TransportType getTransportType() { + return TransportType.HTML_FILE; + } + + @Override + protected MediaType getContentType() { + return new MediaType("text", "html", Charset.forName("UTF-8")); + } + + @Override + public StreamingServerSockJsSession createSession(String sessionId, WebSocketHandler handler) { + Assert.notNull(getSockJsConfig(), "This transport requires SockJsConfiguration"); + + return new StreamingServerSockJsSession(sessionId, getSockJsConfig(), handler) { + + @Override + protected void writePrelude() throws IOException { + // we already validated the parameter.. + String callback = getRequest().getQueryParams().getFirst("c"); + + String html = String.format(PARTIAL_HTML_CONTENT, callback); + getResponse().getBody().write(html.getBytes("UTF-8")); + getResponse().flush(); + } + }; + } + + @Override + public void handleRequestInternal(ServerHttpRequest request, ServerHttpResponse response, + AbstractHttpServerSockJsSession session) throws TransportErrorException { + + try { + String callback = request.getQueryParams().getFirst("c"); + if (! StringUtils.hasText(callback)) { + response.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR); + response.getBody().write("\"callback\" parameter required".getBytes("UTF-8")); + return; + } + } + catch (Throwable t) { + throw new TransportErrorException("Failed to send error to client", t, session.getId()); + } + + super.handleRequestInternal(request, response, session); + } + + @Override + protected FrameFormat getFrameFormat(ServerHttpRequest request) { + return new DefaultFrameFormat("\r\n") { + @Override + protected String preProcessContent(String content) { + return JavaScriptUtils.javaScriptEscape(content); + } + }; + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/JsonpPollingTransportHandler.java b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/JsonpPollingTransportHandler.java new file mode 100644 index 0000000000..e155c3a5ac --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/JsonpPollingTransportHandler.java @@ -0,0 +1,91 @@ +/* + * 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.sockjs.transport; + +import java.nio.charset.Charset; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.sockjs.SockJsFrame; +import org.springframework.web.socket.sockjs.TransportErrorException; +import org.springframework.web.socket.sockjs.TransportType; +import org.springframework.web.socket.sockjs.SockJsFrame.FrameFormat; +import org.springframework.web.util.JavaScriptUtils; + +/** + * TODO + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +public class JsonpPollingTransportHandler extends AbstractHttpSendingTransportHandler { + + @Override + public TransportType getTransportType() { + return TransportType.JSONP; + } + + @Override + protected MediaType getContentType() { + return new MediaType("application", "javascript", Charset.forName("UTF-8")); + } + + @Override + public PollingServerSockJsSession createSession(String sessionId, WebSocketHandler handler) { + Assert.notNull(getSockJsConfig(), "This transport requires SockJsConfiguration"); + return new PollingServerSockJsSession(sessionId, getSockJsConfig(), handler); + } + + @Override + public void handleRequestInternal(ServerHttpRequest request, ServerHttpResponse response, + AbstractHttpServerSockJsSession session) throws TransportErrorException { + + try { + String callback = request.getQueryParams().getFirst("c"); + if (! StringUtils.hasText(callback)) { + response.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR); + response.getBody().write("\"callback\" parameter required".getBytes("UTF-8")); + return; + } + } + catch (Throwable t) { + throw new TransportErrorException("Failed to send error to client", t, session.getId()); + } + + super.handleRequestInternal(request, response, session); + } + + @Override + protected FrameFormat getFrameFormat(ServerHttpRequest request) { + + // we already validated the parameter.. + String callback = request.getQueryParams().getFirst("c"); + + return new SockJsFrame.DefaultFrameFormat(callback + "(\"%s\");\r\n") { + @Override + protected String preProcessContent(String content) { + return JavaScriptUtils.javaScriptEscape(content); + } + }; + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/JsonpTransportHandler.java b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/JsonpTransportHandler.java new file mode 100644 index 0000000000..6cedeea2c0 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/JsonpTransportHandler.java @@ -0,0 +1,73 @@ +/* + * 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.sockjs.transport; + +import java.io.IOException; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.web.socket.sockjs.AbstractSockJsSession; +import org.springframework.web.socket.sockjs.TransportErrorException; +import org.springframework.web.socket.sockjs.TransportType; + +public class JsonpTransportHandler extends AbstractHttpReceivingTransportHandler { + + @Override + public TransportType getTransportType() { + return TransportType.JSONP_SEND; + } + + @Override + public void handleRequestInternal(ServerHttpRequest request, ServerHttpResponse response, + AbstractSockJsSession sockJsSession) throws TransportErrorException { + + if (MediaType.APPLICATION_FORM_URLENCODED.equals(request.getHeaders().getContentType())) { + if (request.getQueryParams().getFirst("d") == null) { + sendInternalServerError(response, "Payload expected.", sockJsSession.getId()); + return; + } + } + + super.handleRequestInternal(request, response, sockJsSession); + + try { + response.getBody().write("ok".getBytes("UTF-8")); + } + catch (Throwable t) { + throw new TransportErrorException("Failed to write response body", t, sockJsSession.getId()); + } + } + + @Override + protected String[] readMessages(ServerHttpRequest request) throws IOException { + if (MediaType.APPLICATION_FORM_URLENCODED.equals(request.getHeaders().getContentType())) { + String d = request.getQueryParams().getFirst("d"); + return getObjectMapper().readValue(d, String[].class); + } + else { + return getObjectMapper().readValue(request.getBody(), String[].class); + } + } + + @Override + protected HttpStatus getResponseStatus() { + return HttpStatus.OK; + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/PollingServerSockJsSession.java b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/PollingServerSockJsSession.java new file mode 100644 index 0000000000..75e6945089 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/PollingServerSockJsSession.java @@ -0,0 +1,46 @@ +/* + * 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.sockjs.transport; + +import java.io.IOException; + +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.sockjs.SockJsConfiguration; +import org.springframework.web.socket.sockjs.SockJsFrame; + + +public class PollingServerSockJsSession extends AbstractHttpServerSockJsSession { + + public PollingServerSockJsSession(String sessionId, SockJsConfiguration config, WebSocketHandler handler) { + super(sessionId, config, handler); + } + + @Override + protected void flushCache() throws IOException { + cancelHeartbeat(); + String[] messages = getMessageCache().toArray(new String[getMessageCache().size()]); + getMessageCache().clear(); + writeFrame(SockJsFrame.messageFrame(messages)); + } + + @Override + protected void writeFrame(SockJsFrame frame) throws IOException { + super.writeFrame(frame); + resetRequest(); + } + +} + diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/SockJsWebSocketHandler.java b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/SockJsWebSocketHandler.java new file mode 100644 index 0000000000..147ae65d3d --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/SockJsWebSocketHandler.java @@ -0,0 +1,90 @@ +/* + * 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.sockjs.transport; + +import java.util.concurrent.atomic.AtomicInteger; + +import org.springframework.util.Assert; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.adapter.TextWebSocketHandlerAdapter; +import org.springframework.web.socket.sockjs.SockJsConfiguration; + + +/** + * A wrapper around a {@link WebSocketHandler} instance that parses and adds SockJS + * messages frames and also sends SockJS heartbeat messages. + * + *

+ * Implementations of the {@link WebSocketHandler} interface in this class allow + * exceptions from the wrapped {@link WebSocketHandler} to propagate. However, any + * exceptions resulting from SockJS message handling (e.g. while sending SockJS frames or + * heartbeat messages) are caught and treated as transport errors, i.e. routed to the + * {@link WebSocketHandler#handleTransportError(WebSocketSession, Throwable) + * handleTransportError} method of the wrapped handler and the session closed. + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +public class SockJsWebSocketHandler extends TextWebSocketHandlerAdapter { + + private final SockJsConfiguration sockJsConfig; + + private WebSocketServerSockJsSession session; + + private final AtomicInteger sessionCount = new AtomicInteger(0); + + + public SockJsWebSocketHandler(SockJsConfiguration config, + WebSocketHandler webSocketHandler, WebSocketServerSockJsSession session) { + + Assert.notNull(config, "sockJsConfig is required"); + Assert.notNull(webSocketHandler, "webSocketHandler is required"); + Assert.notNull(session, "session is required"); + + this.sockJsConfig = config; + this.session = session; + } + + protected SockJsConfiguration getSockJsConfig() { + return this.sockJsConfig; + } + + @Override + public void afterConnectionEstablished(WebSocketSession wsSession) throws Exception { + Assert.isTrue(this.sessionCount.compareAndSet(0, 1), "Unexpected connection"); + this.session.initWebSocketSession(wsSession); + } + + @Override + public void handleTextMessage(WebSocketSession wsSession, TextMessage message) throws Exception { + this.session.handleMessage(message, wsSession); + } + + @Override + public void afterConnectionClosed(WebSocketSession wsSession, CloseStatus status) throws Exception { + this.session.delegateConnectionClosed(status); + } + + @Override + public void handleTransportError(WebSocketSession webSocketSession, Throwable exception) throws Exception { + this.session.delegateError(exception); + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/StreamingServerSockJsSession.java b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/StreamingServerSockJsSession.java new file mode 100644 index 0000000000..fe37568a61 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/StreamingServerSockJsSession.java @@ -0,0 +1,92 @@ +/* + * 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.sockjs.transport; + +import java.io.IOException; + +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.sockjs.SockJsConfiguration; +import org.springframework.web.socket.sockjs.SockJsFrame; +import org.springframework.web.socket.sockjs.TransportErrorException; +import org.springframework.web.socket.sockjs.SockJsFrame.FrameFormat; + +public class StreamingServerSockJsSession extends AbstractHttpServerSockJsSession { + + private int byteCount; + + + public StreamingServerSockJsSession(String sessionId, SockJsConfiguration config, WebSocketHandler handler) { + super(sessionId, config, handler); + } + + + @Override + public synchronized void setInitialRequest(ServerHttpRequest request, ServerHttpResponse response, + FrameFormat frameFormat) throws TransportErrorException { + + super.setInitialRequest(request, response, frameFormat); + + // the WebSocketHandler delegate may have closed the session + if (!isClosed()) { + super.setLongPollingRequest(request, response, frameFormat); + } + } + + protected void flushCache() throws IOException { + + cancelHeartbeat(); + + do { + String message = getMessageCache().poll(); + SockJsFrame frame = SockJsFrame.messageFrame(message); + writeFrame(frame); + + this.byteCount += frame.getContentBytes().length + 1; + if (logger.isTraceEnabled()) { + logger.trace(this.byteCount + " bytes written so far, " + + getMessageCache().size() + " more messages not flushed"); + } + if (this.byteCount >= getSockJsConfig().getStreamBytesLimit()) { + if (logger.isTraceEnabled()) { + logger.trace("Streamed bytes limit reached. Recycling current request"); + } + resetRequest(); + break; + } + } while (!getMessageCache().isEmpty()); + + scheduleHeartbeat(); + } + + @Override + protected synchronized void resetRequest() { + super.resetRequest(); + this.byteCount = 0; + } + + @Override + protected synchronized void writeFrameInternal(SockJsFrame frame) throws IOException { + if (isActive()) { + super.writeFrameInternal(frame); + getResponse().flush(); + } + } + +} + diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/WebSocketServerSockJsSession.java b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/WebSocketServerSockJsSession.java new file mode 100644 index 0000000000..48bd272d7e --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/WebSocketServerSockJsSession.java @@ -0,0 +1,107 @@ +/* + * 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.sockjs.transport; + +import java.io.IOException; + +import org.springframework.util.StringUtils; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.sockjs.AbstractServerSockJsSession; +import org.springframework.web.socket.sockjs.SockJsConfiguration; +import org.springframework.web.socket.sockjs.SockJsFrame; + +import com.fasterxml.jackson.databind.ObjectMapper; + + +/** + * @author Rossen Stoyanchev + * @since 4.0 + */ +public class WebSocketServerSockJsSession extends AbstractServerSockJsSession { + + private WebSocketSession webSocketSession; + + // TODO: JSON library used must be configurable + private final ObjectMapper objectMapper = new ObjectMapper(); + + + public WebSocketServerSockJsSession(String sessionId, SockJsConfiguration config, WebSocketHandler handler) { + super(sessionId, config, handler); + } + + public void initWebSocketSession(WebSocketSession session) throws Exception { + this.webSocketSession = session; + try { + TextMessage message = new TextMessage(SockJsFrame.openFrame().getContent()); + this.webSocketSession.sendMessage(message); + } + catch (IOException ex) { + tryCloseWithSockJsTransportError(ex, null); + return; + } + scheduleHeartbeat(); + delegateConnectionEstablished(); + } + + @Override + public boolean isActive() { + return ((this.webSocketSession != null) && this.webSocketSession.isOpen()); + } + + public void handleMessage(TextMessage message, WebSocketSession wsSession) throws Exception { + String payload = message.getPayload(); + if (StringUtils.isEmpty(payload)) { + logger.trace("Ignoring empty message"); + return; + } + String[] messages; + try { + messages = objectMapper.readValue(payload, String[].class); + } + catch (IOException ex) { + logger.error("Broken data received. Terminating WebSocket connection abruptly", ex); + tryCloseWithSockJsTransportError(ex, CloseStatus.BAD_DATA); + return; + } + delegateMessages(messages); + } + + @Override + public void sendMessageInternal(String message) throws IOException { + cancelHeartbeat(); + writeFrame(SockJsFrame.messageFrame(message)); + scheduleHeartbeat(); + } + + @Override + protected void writeFrameInternal(SockJsFrame frame) throws IOException { + if (logger.isTraceEnabled()) { + logger.trace("Write " + frame); + } + TextMessage message = new TextMessage(frame.getContent()); + this.webSocketSession.sendMessage(message); + } + + @Override + protected void disconnect(CloseStatus status) throws IOException { + this.webSocketSession.close(status); + } +} + diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/WebSocketTransportHandler.java b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/WebSocketTransportHandler.java new file mode 100644 index 0000000000..5db64bddaf --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/WebSocketTransportHandler.java @@ -0,0 +1,94 @@ +/* + * 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.sockjs.transport; + +import java.io.IOException; + +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.util.Assert; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.HandshakeHandler; +import org.springframework.web.socket.sockjs.AbstractSockJsSession; +import org.springframework.web.socket.sockjs.ConfigurableTransportHandler; +import org.springframework.web.socket.sockjs.SockJsConfiguration; +import org.springframework.web.socket.sockjs.SockJsSessionFactory; +import org.springframework.web.socket.sockjs.TransportErrorException; +import org.springframework.web.socket.sockjs.TransportHandler; +import org.springframework.web.socket.sockjs.TransportType; + + +/** + * A WebSocket {@link TransportHandler} that delegates to a {@link HandshakeHandler} + * passing a SockJS {@link WebSocketHandler}. Also implements {@link HandshakeHandler} + * directly in support for raw WebSocket communication at SockJS URL "/websocket". + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +public class WebSocketTransportHandler implements ConfigurableTransportHandler, + HandshakeHandler, SockJsSessionFactory { + + private final HandshakeHandler handshakeHandler; + + private SockJsConfiguration sockJsConfig; + + + public WebSocketTransportHandler(HandshakeHandler handshakeHandler) { + Assert.notNull(handshakeHandler, "handshakeHandler is required"); + this.handshakeHandler = handshakeHandler; + } + + @Override + public TransportType getTransportType() { + return TransportType.WEBSOCKET; + } + + @Override + public void setSockJsConfiguration(SockJsConfiguration sockJsConfig) { + this.sockJsConfig = sockJsConfig; + } + + @Override + public AbstractSockJsSession createSession(String sessionId, WebSocketHandler webSocketHandler) { + return new WebSocketServerSockJsSession(sessionId, this.sockJsConfig, webSocketHandler); + } + + @Override + public void handleRequest(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler webSocketHandler, AbstractSockJsSession session) throws TransportErrorException { + + try { + WebSocketServerSockJsSession wsSession = (WebSocketServerSockJsSession) session; + WebSocketHandler sockJsWrapper = new SockJsWebSocketHandler(this.sockJsConfig, webSocketHandler, wsSession); + this.handshakeHandler.doHandshake(request, response, sockJsWrapper); + } + catch (Throwable t) { + throw new TransportErrorException("Failed to start handshake request", t, session.getId()); + } + } + + // HandshakeHandler methods + + @Override + public boolean doHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler handler) + throws IOException { + + return this.handshakeHandler.doHandshake(request, response, handler); + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/XhrPollingTransportHandler.java b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/XhrPollingTransportHandler.java new file mode 100644 index 0000000000..93adce579e --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/XhrPollingTransportHandler.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.sockjs.transport; + +import java.nio.charset.Charset; + +import org.springframework.http.MediaType; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.util.Assert; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.sockjs.TransportType; +import org.springframework.web.socket.sockjs.SockJsFrame.DefaultFrameFormat; +import org.springframework.web.socket.sockjs.SockJsFrame.FrameFormat; + + +/** + * TODO + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +public class XhrPollingTransportHandler extends AbstractHttpSendingTransportHandler { + + + @Override + public TransportType getTransportType() { + return TransportType.XHR; + } + + @Override + protected MediaType getContentType() { + return new MediaType("application", "javascript", Charset.forName("UTF-8")); + } + + @Override + protected FrameFormat getFrameFormat(ServerHttpRequest request) { + return new DefaultFrameFormat("%s\n"); + } + + public PollingServerSockJsSession createSession(String sessionId, WebSocketHandler handler) { + Assert.notNull(getSockJsConfig(), "This transport requires SockJsConfiguration"); + return new PollingServerSockJsSession(sessionId, getSockJsConfig(), handler); + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/XhrStreamingTransportHandler.java b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/XhrStreamingTransportHandler.java new file mode 100644 index 0000000000..f99f27bdf3 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/XhrStreamingTransportHandler.java @@ -0,0 +1,71 @@ +/* + * 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.sockjs.transport; + +import java.io.IOException; +import java.nio.charset.Charset; + +import org.springframework.http.MediaType; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.util.Assert; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.sockjs.TransportType; +import org.springframework.web.socket.sockjs.SockJsFrame.DefaultFrameFormat; +import org.springframework.web.socket.sockjs.SockJsFrame.FrameFormat; + + +/** + * TODO + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +public class XhrStreamingTransportHandler extends AbstractHttpSendingTransportHandler { + + + @Override + public TransportType getTransportType() { + return TransportType.XHR_STREAMING; + } + + @Override + protected MediaType getContentType() { + return new MediaType("application", "javascript", Charset.forName("UTF-8")); + } + + @Override + public StreamingServerSockJsSession createSession(String sessionId, WebSocketHandler handler) { + Assert.notNull(getSockJsConfig(), "This transport requires SockJsConfiguration"); + + return new StreamingServerSockJsSession(sessionId, getSockJsConfig(), handler) { + + @Override + protected void writePrelude() throws IOException { + for (int i=0; i < 2048; i++) { + getResponse().getBody().write('h'); + } + getResponse().getBody().write('\n'); + getResponse().flush(); + } + }; + } + + @Override + protected FrameFormat getFrameFormat(ServerHttpRequest request) { + return new DefaultFrameFormat("%s\n"); + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/XhrTransportHandler.java b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/XhrTransportHandler.java new file mode 100644 index 0000000000..9d752c4496 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/XhrTransportHandler.java @@ -0,0 +1,42 @@ +/* + * 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.sockjs.transport; + +import java.io.IOException; + +import org.springframework.http.HttpStatus; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.web.socket.sockjs.TransportType; + +public class XhrTransportHandler extends AbstractHttpReceivingTransportHandler { + + + @Override + public TransportType getTransportType() { + return TransportType.XHR_SEND; + } + + @Override + protected String[] readMessages(ServerHttpRequest request) throws IOException { + return getObjectMapper().readValue(request.getBody(), String[].class); + } + + @Override + protected HttpStatus getResponseStatus() { + return HttpStatus.NO_CONTENT; + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/package-info.java b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/package-info.java new file mode 100644 index 0000000000..976e512ad3 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/sockjs/transport/package-info.java @@ -0,0 +1,26 @@ +/* + * 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. + */ + +/** + * Server-side support for SockJS transports including + * {@link org.springframework.web.socket.sockjs.TransportHandler} implementations + * for processing incoming requests and their + * {@link org.springframework.sockjs.SockJsSession} counterparts for + * sending messages over the various transports. + * + */ +package org.springframework.web.socket.sockjs.transport; + diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/support/BeanCreatingHandlerProvider.java b/spring-websocket/src/main/java/org/springframework/web/socket/support/BeanCreatingHandlerProvider.java new file mode 100644 index 0000000000..9f53215b37 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/support/BeanCreatingHandlerProvider.java @@ -0,0 +1,86 @@ +/* + * 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.support; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.beans.factory.config.AutowireCapableBeanFactory; +import org.springframework.util.Assert; + +/** + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +public class BeanCreatingHandlerProvider implements BeanFactoryAware { + + private static final Log logger = LogFactory.getLog(BeanCreatingHandlerProvider.class); + + private final Class handlerType; + + private AutowireCapableBeanFactory beanFactory; + + + public BeanCreatingHandlerProvider(Class handlerType) { + Assert.notNull(handlerType, "handlerType is required"); + this.handlerType = handlerType; + } + + + @Override + public void setBeanFactory(BeanFactory beanFactory) throws BeansException { + if (beanFactory instanceof AutowireCapableBeanFactory) { + this.beanFactory = (AutowireCapableBeanFactory) beanFactory; + } + } + + public Class getHandlerType() { + return this.handlerType; + } + + public T getHandler() { + if (logger.isTraceEnabled()) { + logger.trace("Creating instance for handler type " + this.handlerType); + } + if (this.beanFactory == null) { + logger.warn("No BeanFactory available, attempting to use default constructor"); + return BeanUtils.instantiate(this.handlerType); + } + else { + return this.beanFactory.createBean(this.handlerType); + } + } + + public void destroy(T handler) { + if (this.beanFactory != null) { + if (logger.isTraceEnabled()) { + logger.trace("Destroying handler instance " + handler); + } + this.beanFactory.destroyBean(handler); + } + } + + @Override + public String toString() { + return "BeanCreatingHandlerProvider [handlerClass=" + handlerType + "]"; + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/support/ExceptionWebSocketHandlerDecorator.java b/spring-websocket/src/main/java/org/springframework/web/socket/support/ExceptionWebSocketHandlerDecorator.java new file mode 100644 index 0000000000..bb9f84409a --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/support/ExceptionWebSocketHandlerDecorator.java @@ -0,0 +1,92 @@ +/* + * 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.support; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.WebSocketMessage; +import org.springframework.web.socket.WebSocketSession; + + +/** + * @author Rossen Stoyanchev + * @since 4.0 + */ +public class ExceptionWebSocketHandlerDecorator extends WebSocketHandlerDecorator { + + private Log logger = LogFactory.getLog(ExceptionWebSocketHandlerDecorator.class); + + + public ExceptionWebSocketHandlerDecorator(WebSocketHandler delegate) { + super(delegate); + } + + @Override + public void afterConnectionEstablished(WebSocketSession session) { + try { + getDelegate().afterConnectionEstablished(session); + } + catch (Throwable ex) { + tryCloseWithError(session, ex, logger); + } + } + + public static void tryCloseWithError(WebSocketSession session, Throwable exception, Log logger) { + logger.error("Closing due to exception for " + session, exception); + if (session.isOpen()) { + try { + session.close(CloseStatus.SERVER_ERROR); + } + catch (Throwable t) { + // ignore + } + } + } + + @Override + public void handleMessage(WebSocketSession session, WebSocketMessage message) { + try { + getDelegate().handleMessage(session, message); + } + catch (Throwable ex) { + tryCloseWithError(session, ex, logger); + } + } + + @Override + public void handleTransportError(WebSocketSession session, Throwable exception) { + try { + getDelegate().handleTransportError(session, exception); + } + catch (Throwable ex) { + tryCloseWithError(session, ex, logger); + } + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) { + try { + getDelegate().afterConnectionClosed(session, closeStatus); + } + catch (Throwable t) { + logger.error("Unhandled error for " + this, t); + } + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/support/LoggingWebSocketHandlerDecorator.java b/spring-websocket/src/main/java/org/springframework/web/socket/support/LoggingWebSocketHandlerDecorator.java new file mode 100644 index 0000000000..be5433e2a8 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/support/LoggingWebSocketHandlerDecorator.java @@ -0,0 +1,73 @@ +/* + * 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.support; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.WebSocketMessage; +import org.springframework.web.socket.WebSocketSession; + + +/** + * @author Rossen Stoyanchev + * @since 4.0 + */ +public class LoggingWebSocketHandlerDecorator extends WebSocketHandlerDecorator { + + private Log logger = LogFactory.getLog(LoggingWebSocketHandlerDecorator.class); + + + public LoggingWebSocketHandlerDecorator(WebSocketHandler delegate) { + super(delegate); + } + + + @Override + public void afterConnectionEstablished(WebSocketSession session) throws Exception { + if (logger.isDebugEnabled()) { + logger.debug("Connection established, " + session + ", uri=" + session.getUri()); + } + super.afterConnectionEstablished(session); + } + + @Override + public void handleMessage(WebSocketSession session, WebSocketMessage message) throws Exception { + if (logger.isTraceEnabled()) { + logger.trace("Received " + message + ", " + session); + } + super.handleMessage(session, message); + } + + @Override + public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { + if (logger.isDebugEnabled()) { + logger.debug("Transport error for " + session, exception); + } + super.handleTransportError(session, exception); + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception { + if (logger.isDebugEnabled()) { + logger.debug("Connection closed for " + session + ", " + closeStatus); + } + super.afterConnectionClosed(session, closeStatus); + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/support/PerConnectionWebSocketHandler.java b/spring-websocket/src/main/java/org/springframework/web/socket/support/PerConnectionWebSocketHandler.java new file mode 100644 index 0000000000..8727f1afaf --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/support/PerConnectionWebSocketHandler.java @@ -0,0 +1,133 @@ +/* + * 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.support; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.util.Assert; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.WebSocketMessage; +import org.springframework.web.socket.WebSocketSession; + +/** + * A {@link WebSocketHandler} that initializes and destroys a {@link WebSocketHandler} + * instance for each WebSocket connection and delegates all other methods to it. + * + *

+ * Essentially create an instance of this class once, providing the type of + * {@link WebSocketHandler} class to create for each connection, and then pass it to any + * API method that expects a {@link WebSocketHandler}. + * + *

+ * If initializing the target {@link WebSocketHandler} type requires a Spring BeanFctory, + * then the {@link #setBeanFactory(BeanFactory)} property accordingly. Simply declaring + * this class as a Spring bean will do that. Otherwise, {@link WebSocketHandler} instances + * of the target type will be created using the default constructor. + * + * @author Rossen Stoyanchev + * @since 4.0 + */ +public class PerConnectionWebSocketHandler implements WebSocketHandler, BeanFactoryAware { + + private static final Log logger = LogFactory.getLog(PerConnectionWebSocketHandler.class); + + private final BeanCreatingHandlerProvider provider; + + private final Map handlers = + new ConcurrentHashMap(); + + private final boolean streaming; + + + public PerConnectionWebSocketHandler(Class handlerType) { + this(handlerType, false); + } + + public PerConnectionWebSocketHandler(Class handlerType, boolean isStreaming) { + this.provider = new BeanCreatingHandlerProvider(handlerType); + this.streaming = isStreaming; + } + + + @Override + public boolean isStreaming() { + return this.streaming; + } + + @Override + public void setBeanFactory(BeanFactory beanFactory) throws BeansException { + this.provider.setBeanFactory(beanFactory); + } + + @Override + public void afterConnectionEstablished(WebSocketSession session) throws Exception { + WebSocketHandler handler = this.provider.getHandler(); + this.handlers.put(session, handler); + handler.afterConnectionEstablished(session); + } + + @Override + public void handleMessage(WebSocketSession session, WebSocketMessage message) throws Exception { + getHandler(session).handleMessage(session, message); + } + + private WebSocketHandler getHandler(WebSocketSession session) { + WebSocketHandler handler = this.handlers.get(session); + Assert.isTrue(handler != null, "WebSocketHandler not found for " + session); + return handler; + } + + @Override + public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { + getHandler(session).handleTransportError(session, exception); + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception { + try { + getHandler(session).afterConnectionClosed(session, closeStatus); + } + finally { + destroy(session); + } + } + + private void destroy(WebSocketSession session) { + WebSocketHandler handler = this.handlers.remove(session); + try { + if (handler != null) { + this.provider.destroy(handler); + } + } + catch (Throwable t) { + logger.warn("Error while destroying handler", t); + } + } + + @Override + public String toString() { + return "PerConnectionWebSocketHandlerProxy [handlerType=" + this.provider.getHandlerType() + "]"; + } + +} diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/support/WebSocketHandlerDecorator.java b/spring-websocket/src/main/java/org/springframework/web/socket/support/WebSocketHandlerDecorator.java new file mode 100644 index 0000000000..96ab730434 --- /dev/null +++ b/spring-websocket/src/main/java/org/springframework/web/socket/support/WebSocketHandlerDecorator.java @@ -0,0 +1,75 @@ +/* + * 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.support; + +import org.springframework.util.Assert; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.WebSocketMessage; +import org.springframework.web.socket.WebSocketSession; + + +/** + * @author Rossen Stoyanchev + * @since 4.0 + */ +public class WebSocketHandlerDecorator implements WebSocketHandler { + + private final WebSocketHandler delegate; + + + public WebSocketHandlerDecorator(WebSocketHandler delegate) { + Assert.notNull(delegate, "delegate is required"); + this.delegate = delegate; + } + + + protected WebSocketHandler getDelegate() { + return this.delegate; + } + + @Override + public void afterConnectionEstablished(WebSocketSession session) throws Exception { + this.delegate.afterConnectionEstablished(session); + } + + @Override + public void handleMessage(WebSocketSession session, WebSocketMessage message) throws Exception { + this.delegate.handleMessage(session, message); + } + + @Override + public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { + this.delegate.handleTransportError(session, exception); + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception { + this.delegate.afterConnectionClosed(session, closeStatus); + } + + @Override + public boolean isStreaming() { + return this.delegate.isStreaming(); + } + + @Override + public String toString() { + return getClass().getSimpleName() + " [delegate=" + this.delegate + "]"; + } + +} diff --git a/spring-websocket/src/main/resources/.gitignore b/spring-websocket/src/main/resources/.gitignore new file mode 100644 index 0000000000..e69de29bb2 diff --git a/spring-websocket/src/test/java/.gitignore b/spring-websocket/src/test/java/.gitignore new file mode 100644 index 0000000000..e69de29bb2 diff --git a/spring-websocket/src/test/java/org/springframework/web/socket/AbstractHttpRequestTests.java b/spring-websocket/src/test/java/org/springframework/web/socket/AbstractHttpRequestTests.java new file mode 100644 index 0000000000..7ef451307e --- /dev/null +++ b/spring-websocket/src/test/java/org/springframework/web/socket/AbstractHttpRequestTests.java @@ -0,0 +1,61 @@ +/* + * 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 org.junit.Before; +import org.springframework.http.server.AsyncServletServerHttpRequest; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpResponse; +import org.springframework.mock.web.test.MockHttpServletRequest; +import org.springframework.mock.web.test.MockHttpServletResponse; + + +/** + * @author Rossen Stoyanchev + */ +public class AbstractHttpRequestTests { + + protected ServerHttpRequest request; + + protected ServerHttpResponse response; + + protected MockHttpServletRequest servletRequest; + + protected MockHttpServletResponse servletResponse; + + + @Before + public void setUp() { + this.servletRequest = new MockHttpServletRequest(); + this.servletResponse = new MockHttpServletResponse(); + this.request = new AsyncServletServerHttpRequest(this.servletRequest, this.servletResponse); + this.response = new ServletServerHttpResponse(this.servletResponse); + } + + + protected void setRequest(String method, String requestUri) { + this.servletRequest.setMethod(method); + this.servletRequest.setRequestURI(requestUri); + } + + protected void resetResponse() { + this.servletResponse = new MockHttpServletResponse(); + this.response = new ServletServerHttpResponse(this.servletResponse); + } + +} diff --git a/spring-websocket/src/test/java/org/springframework/web/socket/sockjs/AbstractSockJsServiceTests.java b/spring-websocket/src/test/java/org/springframework/web/socket/sockjs/AbstractSockJsServiceTests.java new file mode 100644 index 0000000000..fcc5325127 --- /dev/null +++ b/spring-websocket/src/test/java/org/springframework/web/socket/sockjs/AbstractSockJsServiceTests.java @@ -0,0 +1,145 @@ +/* + * 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.sockjs; + +import java.io.IOException; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; +import org.springframework.web.socket.AbstractHttpRequestTests; +import org.springframework.web.socket.WebSocketHandler; + +import static org.junit.Assert.*; + +/** + * @author Rossen Stoyanchev + */ +public class AbstractSockJsServiceTests extends AbstractHttpRequestTests { + + private TestSockJsService service; + + private WebSocketHandler handler; + + + @Before + public void setUp() { + super.setUp(); + this.service = new TestSockJsService(new ThreadPoolTaskScheduler()); + } + + @Test + public void getSockJsPath() throws Exception { + + handleRequest("/echo", HttpStatus.OK); + assertEquals("Welcome to SockJS!\n", this.servletResponse.getContentAsString()); + + handleRequest("/echo/info", HttpStatus.OK); + assertTrue(this.servletResponse.getContentAsString().startsWith("{\"entropy\":")); + + handleRequest("/echo/", HttpStatus.OK); + assertEquals("Welcome to SockJS!\n", this.servletResponse.getContentAsString()); + + handleRequest("/echo/iframe.html", HttpStatus.OK); + assertTrue(this.servletResponse.getContentAsString().startsWith("\n")); + + handleRequest("/echo/websocket", HttpStatus.OK); + assertNull(this.service.sessionId); + assertSame(this.handler, this.service.handler); + + handleRequest("/echo/server1/session2/xhr", HttpStatus.OK); + assertEquals("session2", this.service.sessionId); + assertEquals(TransportType.XHR, this.service.transportType); + assertSame(this.handler, this.service.handler); + + handleRequest("/echo/other", HttpStatus.NOT_FOUND); + handleRequest("/echo//", HttpStatus.NOT_FOUND); + handleRequest("/echo///", HttpStatus.NOT_FOUND); + } + + + @Test + public void getSockJsPathGreetingRequest() throws Exception { + handleRequest("/echo", HttpStatus.OK); + assertEquals("Welcome to SockJS!\n", this.servletResponse.getContentAsString()); + } + + @Test + public void getSockJsPathInfoRequest() throws Exception { + handleRequest("/echo/info", HttpStatus.OK); + assertTrue(this.servletResponse.getContentAsString().startsWith("{\"entropy\":")); + } + + @Test + public void getSockJsPathWithConfiguredPrefix() throws Exception { + this.service.setValidSockJsPrefixes("/echo"); + handleRequest("/echo/s1/s2/xhr", HttpStatus.OK); + } + + @Test + public void getInfoOptions() throws Exception { + setRequest("OPTIONS", "/echo/info"); + this.service.handleRequest(this.request, this.response, this.handler); + + assertEquals(204, servletResponse.getStatus()); + } + + + private void handleRequest(String uri, HttpStatus httpStatus) throws IOException { + resetResponse(); + setRequest("GET", uri); + this.service.handleRequest(this.request, this.response, this.handler); + + assertEquals(httpStatus.value(), this.servletResponse.getStatus()); + } + + private static class TestSockJsService extends AbstractSockJsService { + + private String sessionId; + + private TransportType transportType; + + private WebSocketHandler handler; + + public TestSockJsService(TaskScheduler scheduler) { + super(scheduler); + } + + @Override + protected void handleRawWebSocketRequest(ServerHttpRequest request, + ServerHttpResponse response, WebSocketHandler handler) throws IOException { + + this.handler = handler; + } + + @Override + protected void handleTransportRequest(ServerHttpRequest request, + ServerHttpResponse response, String sessionId, + TransportType transportType, WebSocketHandler handler) + throws IOException, TransportErrorException { + + this.sessionId = sessionId; + this.transportType = transportType; + this.handler = handler; + } + } + +} diff --git a/spring-websocket/src/test/java/org/springframework/web/socket/sockjs/StubSockJsConfig.java b/spring-websocket/src/test/java/org/springframework/web/socket/sockjs/StubSockJsConfig.java new file mode 100644 index 0000000000..0f2a4c4f05 --- /dev/null +++ b/spring-websocket/src/test/java/org/springframework/web/socket/sockjs/StubSockJsConfig.java @@ -0,0 +1,59 @@ +/* + * 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.sockjs; + +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + + +/** + * @author Rossen Stoyanchev + */ +public class StubSockJsConfig implements SockJsConfiguration { + + private int streamBytesLimit = 128 * 1024; + + private long heartbeatTime = 25 * 1000; + + private TaskScheduler taskScheduler = new ThreadPoolTaskScheduler(); + + + public int getStreamBytesLimit() { + return streamBytesLimit; + } + + public void setStreamBytesLimit(int streamBytesLimit) { + this.streamBytesLimit = streamBytesLimit; + } + + public long getHeartbeatTime() { + return heartbeatTime; + } + + public void setHeartbeatTime(long heartbeatTime) { + this.heartbeatTime = heartbeatTime; + } + + public TaskScheduler getTaskScheduler() { + return taskScheduler; + } + + public void setTaskScheduler(TaskScheduler taskScheduler) { + this.taskScheduler = taskScheduler; + } + +} diff --git a/spring-websocket/src/test/java/org/springframework/web/socket/sockjs/StubTaskScheduler.java b/spring-websocket/src/test/java/org/springframework/web/socket/sockjs/StubTaskScheduler.java new file mode 100644 index 0000000000..57849d1ba1 --- /dev/null +++ b/spring-websocket/src/test/java/org/springframework/web/socket/sockjs/StubTaskScheduler.java @@ -0,0 +1,87 @@ +/* + * 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.sockjs; + +import java.util.Date; +import java.util.concurrent.Callable; +import java.util.concurrent.Delayed; +import java.util.concurrent.FutureTask; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.Trigger; + + +/** + * @author Rossen Stoyanchev + */ +public class StubTaskScheduler implements TaskScheduler { + + @Override + public ScheduledFuture schedule(Runnable task, Trigger trigger) { + return new StubScheduledFuture(); + } + + @Override + public ScheduledFuture schedule(Runnable task, Date startTime) { + return new StubScheduledFuture(); + } + + @Override + public ScheduledFuture scheduleAtFixedRate(Runnable task, Date startTime, long period) { + return new StubScheduledFuture(); + } + + @Override + public ScheduledFuture scheduleAtFixedRate(Runnable task, long period) { + return new StubScheduledFuture(); + } + + @Override + public ScheduledFuture scheduleWithFixedDelay(Runnable task, Date startTime, long delay) { + return new StubScheduledFuture(); + } + + @Override + public ScheduledFuture scheduleWithFixedDelay(Runnable task, long delay) { + return new StubScheduledFuture(); + } + + + private static class StubScheduledFuture extends FutureTask implements ScheduledFuture { + + @SuppressWarnings("unchecked") + public StubScheduledFuture() { + super(new Callable() { + public Object call() throws Exception { + return null; + } + }); + } + + @Override + public long getDelay(TimeUnit unit) { + return 0; + } + + @Override + public int compareTo(Delayed o) { + return 0; + } + } +} diff --git a/spring-websocket/src/test/java/org/springframework/web/socket/sockjs/TransportTypeTests.java b/spring-websocket/src/test/java/org/springframework/web/socket/sockjs/TransportTypeTests.java new file mode 100644 index 0000000000..9f4b34c17f --- /dev/null +++ b/spring-websocket/src/test/java/org/springframework/web/socket/sockjs/TransportTypeTests.java @@ -0,0 +1,42 @@ +/* + * 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.sockjs; + +import org.junit.Test; + +import static org.junit.Assert.*; + + +/** + * @author Rossen Stoyanchev + */ +public class TransportTypeTests { + + + @Test + public void testFromValue() { + assertEquals(TransportType.WEBSOCKET, TransportType.fromValue("websocket")); + assertEquals(TransportType.XHR, TransportType.fromValue("xhr")); + assertEquals(TransportType.XHR_SEND, TransportType.fromValue("xhr_send")); + assertEquals(TransportType.JSONP, TransportType.fromValue("jsonp")); + assertEquals(TransportType.JSONP_SEND, TransportType.fromValue("jsonp_send")); + assertEquals(TransportType.XHR_STREAMING, TransportType.fromValue("xhr_streaming")); + assertEquals(TransportType.EVENT_SOURCE, TransportType.fromValue("eventsource")); + assertEquals(TransportType.HTML_FILE, TransportType.fromValue("htmlfile")); + } + +} diff --git a/spring-websocket/src/test/java/org/springframework/web/socket/sockjs/support/DefaultSockJsServiceTests.java b/spring-websocket/src/test/java/org/springframework/web/socket/sockjs/support/DefaultSockJsServiceTests.java new file mode 100644 index 0000000000..a8630d5319 --- /dev/null +++ b/spring-websocket/src/test/java/org/springframework/web/socket/sockjs/support/DefaultSockJsServiceTests.java @@ -0,0 +1,82 @@ +/* + * 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.sockjs.support; + +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.web.socket.AbstractHttpRequestTests; +import org.springframework.web.socket.adapter.TextWebSocketHandlerAdapter; +import org.springframework.web.socket.sockjs.StubTaskScheduler; +import org.springframework.web.socket.sockjs.TransportHandler; +import org.springframework.web.socket.sockjs.TransportType; + +import static org.junit.Assert.*; + + +/** + * Test fixture for {@link DefaultSockJsService}. + * + * @author Rossen Stoyanchev + */ +public class DefaultSockJsServiceTests extends AbstractHttpRequestTests { + + private DefaultSockJsService service; + + + @Before + public void setUp() { + super.setUp(); + this.service = new DefaultSockJsService(new StubTaskScheduler()); + this.service.setValidSockJsPrefixes("/echo"); + } + + @Test + public void defaultTransportHandlers() { + + Map handlers = service.getTransportHandlers(); + + assertEquals(8, handlers.size()); + assertNotNull(handlers.get(TransportType.WEBSOCKET)); + assertNotNull(handlers.get(TransportType.XHR)); + assertNotNull(handlers.get(TransportType.XHR_SEND)); + assertNotNull(handlers.get(TransportType.XHR_STREAMING)); + assertNotNull(handlers.get(TransportType.JSONP)); + assertNotNull(handlers.get(TransportType.JSONP_SEND)); + assertNotNull(handlers.get(TransportType.HTML_FILE)); + assertNotNull(handlers.get(TransportType.EVENT_SOURCE)); + } + + @Test + public void xhrSend() throws Exception { + + setRequest("POST", "/echo/000/c5839f69/xhr"); + this.service.handleRequest(this.request, this.response, new TextWebSocketHandlerAdapter()); + + resetResponse(); + setRequest("POST", "/echo/000/c5839f69/xhr_send"); + this.servletRequest.setContent("[\"x\"]".getBytes("UTF-8")); + + this.service.handleRequest(this.request, this.response, new TextWebSocketHandlerAdapter()); + + assertEquals(204, this.servletResponse.getStatus()); + assertEquals("text/plain;charset=UTF-8", this.servletResponse.getContentType()); + } + + +} diff --git a/spring-websocket/src/test/resources/log4j.xml b/spring-websocket/src/test/resources/log4j.xml new file mode 100644 index 0000000000..8fa59bf2f3 --- /dev/null +++ b/spring-websocket/src/test/resources/log4j.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +