diff --git a/spring-web-reactive/.gitignore b/spring-web-reactive/.gitignore new file mode 100644 index 0000000000..0b81cf52be --- /dev/null +++ b/spring-web-reactive/.gitignore @@ -0,0 +1,10 @@ +target +.project +.classpath +.settings +*.iml +/.idea/ +bin +.gradle +/tomcat.*/ +build diff --git a/spring-web-reactive/README.md b/spring-web-reactive/README.md new file mode 100644 index 0000000000..79d5f2b3be --- /dev/null +++ b/spring-web-reactive/README.md @@ -0,0 +1,56 @@ +Spring Reactive is a sandbox for experimenting on the reactive support intended to be part +of Spring Framework 5. For more information about this topic, you can have a look to +[Intro to Reactive programming][] and [Reactive Web Applications][] talks. + +## Downloading Artifacts +Spring Reactive JAR dependency is available from Spring snapshot repository: + - Repository URL: `https://repo.spring.io/snapshot/` + - GroupId: `org.springframework.reactive` + - ArtifactId: `spring-reactive` + - Version: `0.1.0.BUILD-SNAPSHOT` + +## Documentation +See the current [Javadoc][]. + +## Sample application +[Spring Reactive Playground] is a sample application based on Spring Reactive and on MongoDB, +Couchbase and PostgreSQL Reactive database drivers. + +## Building from Source +Spring Reactive uses a [Gradle][]-based build system. In the instructions +below, `./gradlew` is invoked from the root of the source tree and serves as +a cross-platform, self-contained bootstrap mechanism for the build. + +You can check the current build status on this [Bamboo Spring Reactive build][]. + +### Prerequisites + +[Git][] and [JDK 8 update 20 or later][JDK8 build] + +Be sure that your `JAVA_HOME` environment variable points to the `jdk1.8.0` folder +extracted from the JDK download. + +### Install all spring-\* jars into your local Maven cache +`./gradlew install` + +### Compile and test; build all jars, distribution zips, and docs +`./gradlew build` + +## Contributing +Feel free to send us your feedback on the [issue tracker][]; [Pull requests][] are welcome. + +## License +The Spring Reactive is released under version 2.0 of the [Apache License][]. + + +[Spring Reactive Playground]: https://github.com/sdeleuze/spring-reactive-playground +[Gradle]: http://gradle.org +[Bamboo Spring Reactive build]: https://build.spring.io/browse/SR-PUB +[Git]: http://help.github.com/set-up-git-redirect +[JDK8 build]: http://www.oracle.com/technetwork/java/javase/downloads +[Intro to Reactive programming]: http://fr.slideshare.net/StphaneMaldini/intro-to-reactive-programming-52821416 +[Reactive Web Applications]: http://fr.slideshare.net/rstoya05/reactive-web-applications +[issue tracker]: https://github.com/spring-projects/spring-reactive/issues +[Pull requests]: http://help.github.com/send-pull-requests +[Apache License]: http://www.apache.org/licenses/LICENSE-2.0 +[Javadoc]: https://repo.spring.io/snapshot/org/springframework/reactive/spring-reactive/0.1.0.BUILD-SNAPSHOT/spring-reactive-0.1.0.BUILD-SNAPSHOT-javadoc.jar!/overview-summary.html diff --git a/spring-web-reactive/build.gradle b/spring-web-reactive/build.gradle new file mode 100644 index 0000000000..32571e2e98 --- /dev/null +++ b/spring-web-reactive/build.gradle @@ -0,0 +1,135 @@ +buildscript { + repositories { + maven { url 'https://repo.spring.io/plugins-release' } + } + dependencies { + classpath 'org.springframework.build.gradle:propdeps-plugin:0.0.7' + } +} + +apply plugin: 'java' +apply plugin: 'propdeps' +apply plugin: 'propdeps-idea' +apply plugin: 'propdeps-maven' + +jar { + baseName = 'spring-reactive' +} + +group = 'org.springframework.reactive' + +repositories { + mavenLocal() + mavenCentral() + maven { url 'https://oss.jfrog.org/libs-snapshot' } // RxNetty 0.5.x snapshots + maven { url 'http://repo.spring.io/milestone' } // Reactor milestone + maven { url 'http://repo.spring.io/snapshot' } // Reactor snapshot +} + +ext { + springVersion = '5.0.0.BUILD-SNAPSHOT' + reactorVersion = '2.5.0.BUILD-SNAPSHOT' + reactorNettyVersion = '2.5.0.BUILD-SNAPSHOT' + rxJavaVersion = '1.1.6' + tomcatVersion = '8.5.3' + jettyVersion = '9.3.10.v20160621' + nettyVersion = '4.1.2.Final' + jacksonVersion = '2.7.5' + + javadocLinks = [ + "http://docs.oracle.com/javase/8/docs/api/", + "http://projectreactor.io/core/docs/api/", + "http://docs.spring.io/spring/docs/${springVersion}/javadoc-api/", + "http://www.reactive-streams.org/reactive-streams-1.0.0-javadoc/" + ] as String[] +} + +configurations.all { + // check for updates every build + resolutionStrategy.cacheChangingModulesFor 0, 'seconds' + resolutionStrategy.eachDependency { DependencyResolveDetails details -> + // consistent netty version to avoid issues with clashes in netty-all vs + // netty-common for example + if (details.requested.group == 'io.netty') { + details.useVersion nettyVersion + } + } +} + +uploadArchives { + repositories { + mavenDeployer { + uniqueVersion = false + } + } +} + +javadoc { + description = "Generates project-level javadoc for use in -javadoc jar" + + options.memberLevel = org.gradle.external.javadoc.JavadocMemberLevel.PROTECTED + options.author = true + options.header = project.name + options.addStringOption('Xdoclint:none', '-quiet') + options.links(project.ext.javadocLinks) +} + +task sourcesJar(type: Jar, dependsOn: classes) { + classifier = 'sources' + from sourceSets.main.allSource +} + +task javadocJar(type: Jar, dependsOn: javadoc) { + classifier = 'javadoc' + from javadoc.destinationDir +} + +artifacts { + archives sourcesJar + archives javadocJar +} + +dependencies { + compile "org.springframework:spring-core:${springVersion}" + compile "org.springframework:spring-web:${springVersion}" + compile "org.reactivestreams:reactive-streams:1.0.0" + compile "io.projectreactor:reactor-core:${reactorVersion}" + compile "commons-logging:commons-logging:1.2" + compile "io.netty:netty-buffer:${nettyVersion}" // Temporarily for JsonObjectDecoder (GH #116) + + optional "org.springframework:spring-context-support:${springVersion}" // for FreeMarker + optional "io.reactivex:rxjava:${rxJavaVersion}" + optional ("io.reactivex:rxnetty-http:0.5.2-SNAPSHOT") { + exclude group: 'io.reactivex', module: 'rxjava' + } + optional "com.fasterxml.jackson.core:jackson-annotations:${jacksonVersion}" + optional "com.fasterxml.jackson.core:jackson-databind:${jacksonVersion}" + optional "io.projectreactor:reactor-netty:${reactorNettyVersion}" + optional "org.apache.tomcat:tomcat-util:${tomcatVersion}" + optional "org.apache.tomcat.embed:tomcat-embed-core:${tomcatVersion}" + optional 'io.undertow:undertow-core:1.3.20.Final' + optional "org.eclipse.jetty:jetty-server:${jettyVersion}" + optional "org.eclipse.jetty:jetty-servlet:${jettyVersion}" + optional("org.freemarker:freemarker:2.3.23") + optional("com.fasterxml:aalto-xml:1.0.0") + optional("javax.validation:validation-api:1.1.0.Final") + + provided "javax.servlet:javax.servlet-api:3.1.0" + + testCompile "junit:junit:4.12" + testCompile "org.springframework:spring-test:${springVersion}" + testCompile "org.slf4j:slf4j-jcl:1.7.12" + testCompile "org.slf4j:jul-to-slf4j:1.7.12" + testCompile "log4j:log4j:1.2.16" + testCompile("org.mockito:mockito-core:1.10.19") { + exclude group: 'org.hamcrest', module: 'hamcrest-core' + } + testCompile "org.hamcrest:hamcrest-all:1.3" + testCompile "com.squareup.okhttp3:mockwebserver:3.0.1" + testCompile("xmlunit:xmlunit:1.6") + + // Needed to run Javadoc without error + optional "org.apache.httpcomponents:httpclient:4.5.1" +} + + diff --git a/spring-web-reactive/gradle.properties b/spring-web-reactive/gradle.properties new file mode 100644 index 0000000000..09f2a8e531 --- /dev/null +++ b/spring-web-reactive/gradle.properties @@ -0,0 +1,2 @@ +version=0.1.0.BUILD-SNAPSHOT + diff --git a/spring-web-reactive/gradle/wrapper/gradle-wrapper.jar b/spring-web-reactive/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000..30d399d8d2 Binary files /dev/null and b/spring-web-reactive/gradle/wrapper/gradle-wrapper.jar differ diff --git a/spring-web-reactive/gradle/wrapper/gradle-wrapper.properties b/spring-web-reactive/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..329a105dc7 --- /dev/null +++ b/spring-web-reactive/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Aug 07 17:32:01 EDT 2015 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-2.12-all.zip diff --git a/spring-web-reactive/gradlew b/spring-web-reactive/gradlew new file mode 100755 index 0000000000..91a7e269e1 --- /dev/null +++ b/spring-web-reactive/gradlew @@ -0,0 +1,164 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# For Cygwin, ensure paths are in UNIX format before anything is touched. +if $cygwin ; then + [ -n "$JAVA_HOME" ] && JAVA_HOME=`cygpath --unix "$JAVA_HOME"` +fi + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >&- +APP_HOME="`pwd -P`" +cd "$SAVED" >&- + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/spring-web-reactive/gradlew.bat b/spring-web-reactive/gradlew.bat new file mode 100644 index 0000000000..aec99730b4 --- /dev/null +++ b/spring-web-reactive/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/spring-web-reactive/settings.gradle b/spring-web-reactive/settings.gradle new file mode 100644 index 0000000000..4fd62c48f4 --- /dev/null +++ b/spring-web-reactive/settings.gradle @@ -0,0 +1 @@ +rootProject.name = "spring-reactive" diff --git a/spring-web-reactive/src/main/java/org/springframework/core/codec/AbstractDecoder.java b/spring-web-reactive/src/main/java/org/springframework/core/codec/AbstractDecoder.java new file mode 100644 index 0000000000..7f8fc803a4 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/core/codec/AbstractDecoder.java @@ -0,0 +1,61 @@ +/* + * Copyright 2002-2016 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.core.codec; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.reactivestreams.Publisher; +import reactor.core.publisher.Mono; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.util.MimeType; + +/** + * @author Sebastien Deleuze + * @author Arjen Poutsma + */ +public abstract class AbstractDecoder implements Decoder { + + private List decodableMimeTypes = Collections.emptyList(); + + protected AbstractDecoder(MimeType... supportedMimeTypes) { + this.decodableMimeTypes = Arrays.asList(supportedMimeTypes); + } + + + @Override + public List getDecodableMimeTypes() { + return this.decodableMimeTypes; + } + + @Override + public boolean canDecode(ResolvableType elementType, MimeType mimeType, Object... hints) { + if (mimeType == null) { + return true; + } + return this.decodableMimeTypes.stream(). + anyMatch(mt -> mt.isCompatibleWith(mimeType)); + } + + @Override + public Mono decodeToMono(Publisher inputStream, ResolvableType elementType, MimeType mimeType, Object... hints) { + throw new UnsupportedOperationException(); + } +} diff --git a/spring-web-reactive/src/main/java/org/springframework/core/codec/AbstractEncoder.java b/spring-web-reactive/src/main/java/org/springframework/core/codec/AbstractEncoder.java new file mode 100644 index 0000000000..35ce3803bf --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/core/codec/AbstractEncoder.java @@ -0,0 +1,53 @@ +/* + * Copyright 2002-2016 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.core.codec; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.springframework.core.ResolvableType; +import org.springframework.util.MimeType; + +/** + * @author Sebastien Deleuze + * @author Arjen Poutsma + */ +public abstract class AbstractEncoder implements Encoder { + + private List encodableMimeTypes = Collections.emptyList(); + + protected AbstractEncoder(MimeType... supportedMimeTypes) { + this.encodableMimeTypes = Arrays.asList(supportedMimeTypes); + } + + + @Override + public List getEncodableMimeTypes() { + return this.encodableMimeTypes; + } + + @Override + public boolean canEncode(ResolvableType elementType, MimeType mimeType, Object... hints) { + if (mimeType == null) { + return true; + } + return this.encodableMimeTypes.stream(). + anyMatch(mt -> mt.isCompatibleWith(mimeType)); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/core/codec/AbstractSingleValueEncoder.java b/spring-web-reactive/src/main/java/org/springframework/core/codec/AbstractSingleValueEncoder.java new file mode 100644 index 0000000000..ca83b76c79 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/core/codec/AbstractSingleValueEncoder.java @@ -0,0 +1,68 @@ +/* + * Copyright 2002-2016 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.core.codec; + +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.util.MimeType; + +/** + * Abstract base class for {@link org.springframework.core.codec.Encoder} classes that + * can only deal with a single value. + * @author Arjen Poutsma + */ +public abstract class AbstractSingleValueEncoder extends AbstractEncoder { + + public AbstractSingleValueEncoder(MimeType... supportedMimeTypes) { + super(supportedMimeTypes); + } + + @Override + public final Flux encode(Publisher inputStream, + DataBufferFactory bufferFactory, ResolvableType elementType, MimeType mimeType, + Object... hints) { + return Flux.from(inputStream). + take(1). + concatMap(t -> { + try { + return encode(t, bufferFactory, elementType, mimeType); + } + catch (Exception ex) { + return Flux.error(ex); + } + }); + } + + /** + * Encodes {@code T} to an output {@link DataBuffer} stream. + * @param t the value to process + * @param dataBufferFactory a buffer factory used to create the output + * @param type the stream element type to process + * @param mimeType the mime type to process + * @param hints Additional information about how to do decode, optional + * @return the output stream + * @throws Exception in case of errors + */ + protected abstract Flux encode(T t, DataBufferFactory dataBufferFactory, + ResolvableType type, MimeType mimeType, Object... hints) throws Exception; + + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/core/codec/ByteBufferDecoder.java b/spring-web-reactive/src/main/java/org/springframework/core/codec/ByteBufferDecoder.java new file mode 100644 index 0000000000..7f9c8ea6db --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/core/codec/ByteBufferDecoder.java @@ -0,0 +1,60 @@ +/* + * Copyright 2002-2016 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.core.codec; + +import java.nio.ByteBuffer; + +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.support.DataBufferUtils; +import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils; + +/** + * @author Sebastien Deleuze + * @author Arjen Poutsma + */ +public class ByteBufferDecoder extends AbstractDecoder { + + + public ByteBufferDecoder() { + super(MimeTypeUtils.ALL); + } + + + @Override + public boolean canDecode(ResolvableType elementType, MimeType mimeType, Object... hints) { + Class clazz = elementType.getRawClass(); + return (super.canDecode(elementType, mimeType, hints) && ByteBuffer.class.isAssignableFrom(clazz)); + } + + @Override + public Flux decode(Publisher inputStream, ResolvableType elementType, + MimeType mimeType, Object... hints) { + return Flux.from(inputStream).map((dataBuffer) -> { + ByteBuffer copy = ByteBuffer.allocate(dataBuffer.readableByteCount()); + copy.put(dataBuffer.asByteBuffer()); + copy.flip(); + DataBufferUtils.release(dataBuffer); + return copy; + }); + } + +} \ No newline at end of file diff --git a/spring-web-reactive/src/main/java/org/springframework/core/codec/ByteBufferEncoder.java b/spring-web-reactive/src/main/java/org/springframework/core/codec/ByteBufferEncoder.java new file mode 100644 index 0000000000..b3a1cc2f0d --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/core/codec/ByteBufferEncoder.java @@ -0,0 +1,54 @@ +/* + * Copyright 2002-2016 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.core.codec; + +import java.nio.ByteBuffer; + +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils; + +/** + * @author Sebastien Deleuze + */ +public class ByteBufferEncoder extends AbstractEncoder { + + public ByteBufferEncoder() { + super(MimeTypeUtils.ALL); + } + + + @Override + public boolean canEncode(ResolvableType elementType, MimeType mimeType, Object... hints) { + Class clazz = elementType.getRawClass(); + return (super.canEncode(elementType, mimeType, hints) && ByteBuffer.class.isAssignableFrom(clazz)); + } + + @Override + public Flux encode(Publisher inputStream, + DataBufferFactory bufferFactory, ResolvableType elementType, MimeType mimeType, + Object... hints) { + + return Flux.from(inputStream).map(bufferFactory::wrap); + } + +} \ No newline at end of file diff --git a/spring-web-reactive/src/main/java/org/springframework/core/codec/CodecException.java b/spring-web-reactive/src/main/java/org/springframework/core/codec/CodecException.java new file mode 100644 index 0000000000..51718cddd0 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/core/codec/CodecException.java @@ -0,0 +1,36 @@ +/* + * Copyright 2002-2015 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.core.codec; + +import org.springframework.core.NestedRuntimeException; + +/** + * Codec related exception, usually used as a wrapper for a cause exception. + * + * @author Sebastien Deleuze + */ +public class CodecException extends NestedRuntimeException { + + public CodecException(String msg, Throwable cause) { + super(msg, cause); + } + + public CodecException(String msg) { + super(msg); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/core/codec/Decoder.java b/spring-web-reactive/src/main/java/org/springframework/core/codec/Decoder.java new file mode 100644 index 0000000000..1b029b4c79 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/core/codec/Decoder.java @@ -0,0 +1,83 @@ +/* + * Copyright 2002-2016 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.core.codec; + +import java.util.List; + +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.util.MimeType; + +/** + * Strategy for decoding a {@link DataBuffer} input stream into an output stream + * of elements of type {@code }. + * + * @author Sebastien Deleuze + * @author Rossen Stoyanchev + * @param the type of elements in the output stream + */ +public interface Decoder { + + /** + * Whether the decoder supports the given target element type and the MIME + * type of the source stream. + * + * @param elementType the target element type for the output stream + * @param mimeType the mime type associated with the stream to decode + * @param hints additional information about how to do decode, optional + * @return {@code true} if supported, {@code false} otherwise + */ + boolean canDecode(ResolvableType elementType, MimeType mimeType, Object... hints); + + /** + * Decode a {@link DataBuffer} input stream into a Flux of {@code T}. + * + * @param inputStream the {@code DataBuffer} input stream to decode + * @param elementType the expected type of elements in the output stream; + * this type must have been previously passed to the {@link #canDecode} + * method and it must have returned {@code true}. + * @param mimeType the MIME type associated with the input stream, optional + * @param hints additional information about how to do decode, optional + * @return the output stream with decoded elements + */ + Flux decode(Publisher inputStream, ResolvableType elementType, + MimeType mimeType, Object... hints); + + /** + * Decode a {@link DataBuffer} input stream into a Mono of {@code T}. + * + * @param inputStream the {@code DataBuffer} input stream to decode + * @param elementType the expected type of elements in the output stream; + * this type must have been previously passed to the {@link #canDecode} + * method and it must have returned {@code true}. + * @param mimeType the MIME type associated with the input stream, optional + * @param hints additional information about how to do decode, optional + * @return the output stream with the decoded element + */ + Mono decodeToMono(Publisher inputStream, ResolvableType elementType, + MimeType mimeType, Object... hints); + + /** + * Return the list of MIME types this decoder supports. + */ + List getDecodableMimeTypes(); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/core/codec/Encoder.java b/spring-web-reactive/src/main/java/org/springframework/core/codec/Encoder.java new file mode 100644 index 0000000000..93ea941ee8 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/core/codec/Encoder.java @@ -0,0 +1,71 @@ +/* + * Copyright 2002-2016 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.core.codec; + +import java.util.List; + +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.util.MimeType; + +/** + * Strategy to encode a stream of Objects of type {@code } into an output + * stream of bytes. + * + * @author Sebastien Deleuze + * @author Rossen Stoyanchev + * @param the type of elements in the input stream + */ +public interface Encoder { + + /** + * Whether the encoder supports the given source element type and the MIME + * type for the output stream. + * + * @param elementType the type of elements in the source stream + * @param mimeType the MIME type for the output stream + * @param hints additional information about how to do encode, optional + * @return {@code true} if supported, {@code false} otherwise + */ + boolean canEncode(ResolvableType elementType, MimeType mimeType, Object... hints); + + /** + * Encode a stream of Objects of type {@code T} into a {@link DataBuffer} + * output stream. + * + * @param inputStream the input stream of Objects to encode + * @param bufferFactory for creating output stream {@code DataBuffer}'s + * @param elementType the expected type of elements in the input stream; + * this type must have been previously passed to the {@link #canEncode} + * method and it must have returned {@code true}. + * @param mimeType the MIME type for the output stream + * @param hints additional information about how to do encode, optional + * @return the output stream + */ + Flux encode(Publisher inputStream, DataBufferFactory bufferFactory, + ResolvableType elementType, MimeType mimeType, Object... hints); + + /** + * Return the list of mime types this encoder supports. + */ + List getEncodableMimeTypes(); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/core/codec/ResourceDecoder.java b/spring-web-reactive/src/main/java/org/springframework/core/codec/ResourceDecoder.java new file mode 100644 index 0000000000..a7a9d45660 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/core/codec/ResourceDecoder.java @@ -0,0 +1,82 @@ +/* + * Copyright 2002-2016 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.core.codec; + +import java.io.ByteArrayInputStream; + +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.support.DataBufferUtils; +import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils; + +/** + * A decoder for {@link Resource}s. + * + * @author Arjen Poutsma + */ +public class ResourceDecoder extends AbstractDecoder { + + public ResourceDecoder() { + super(MimeTypeUtils.ALL); + } + + @Override + public boolean canDecode(ResolvableType elementType, MimeType mimeType, Object... hints) { + Class clazz = elementType.getRawClass(); + return (InputStreamResource.class.equals(clazz) || + clazz.isAssignableFrom(ByteArrayResource.class)) && + super.canDecode(elementType, mimeType, hints); + } + + @Override + public Flux decode(Publisher inputStream, ResolvableType elementType, + MimeType mimeType, Object... hints) { + Class clazz = elementType.getRawClass(); + + Mono byteArray = Flux.from(inputStream). + reduce(DataBuffer::write). + map(dataBuffer -> { + byte[] bytes = new byte[dataBuffer.readableByteCount()]; + dataBuffer.read(bytes); + DataBufferUtils.release(dataBuffer); + return bytes; + }); + + + if (InputStreamResource.class.equals(clazz)) { + return Flux.from(byteArray. + map(ByteArrayInputStream::new). + map(InputStreamResource::new)); + } + else if (clazz.isAssignableFrom(ByteArrayResource.class)) { + return Flux.from(byteArray. + map(ByteArrayResource::new)); + } + else { + return Flux.error(new IllegalStateException( + "Unsupported resource class: " + clazz)); + } + } +} diff --git a/spring-web-reactive/src/main/java/org/springframework/core/codec/ResourceEncoder.java b/spring-web-reactive/src/main/java/org/springframework/core/codec/ResourceEncoder.java new file mode 100644 index 0000000000..3823c4d709 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/core/codec/ResourceEncoder.java @@ -0,0 +1,69 @@ +/* + * Copyright 2002-2016 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.core.codec; + +import java.io.IOException; +import java.io.InputStream; + +import reactor.core.publisher.Flux; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.Resource; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.io.buffer.support.DataBufferUtils; +import org.springframework.util.Assert; +import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils; +import org.springframework.util.StreamUtils; + +/** + * An encoder for {@link Resource}s. + * @author Arjen Poutsma + */ +public class ResourceEncoder extends AbstractSingleValueEncoder { + + public static final int DEFAULT_BUFFER_SIZE = StreamUtils.BUFFER_SIZE; + + private final int bufferSize; + + public ResourceEncoder() { + this(DEFAULT_BUFFER_SIZE); + } + + public ResourceEncoder(int bufferSize) { + super(MimeTypeUtils.ALL); + Assert.isTrue(bufferSize > 0, "'bufferSize' must be larger than 0"); + this.bufferSize = bufferSize; + } + + @Override + public boolean canEncode(ResolvableType elementType, MimeType mimeType, Object... hints) { + Class clazz = elementType.getRawClass(); + return (super.canEncode(elementType, mimeType, hints) && + Resource.class.isAssignableFrom(clazz)); + } + + @Override + protected Flux encode(Resource resource, + DataBufferFactory dataBufferFactory, + ResolvableType type, MimeType mimeType, Object... hints) throws IOException { + InputStream is = resource.getInputStream(); + return DataBufferUtils.read(is, dataBufferFactory, bufferSize); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/core/codec/StringDecoder.java b/spring-web-reactive/src/main/java/org/springframework/core/codec/StringDecoder.java new file mode 100644 index 0000000000..a96e14f536 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/core/codec/StringDecoder.java @@ -0,0 +1,139 @@ +/* + * Copyright 2002-2016 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.core.codec; + +import java.nio.CharBuffer; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.function.IntPredicate; + +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.support.DataBufferUtils; +import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils; + +/** + * Decode from a bytes stream to a String stream. + * + *

By default, this decoder will split the received {@link DataBuffer}s along newline + * characters ({@code \r\n}), but this can be changed by passing {@code false} as + * constructor argument. + * + * @author Sebastien Deleuze + * @author Brian Clozel + * @author Arjen Poutsma + * @author Mark Paluch + * @see StringEncoder + */ +public class StringDecoder extends AbstractDecoder { + + public static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; + + private static final IntPredicate NEWLINE_DELIMITER = b -> b == '\n' || b == '\r'; + + + private final boolean splitOnNewline; + + + /** + * Create a {@code StringDecoder} that decodes a bytes stream to a String stream + * + *

By default, this decoder will split along new lines. + */ + public StringDecoder() { + this(true); + } + + /** + * Create a {@code StringDecoder} that decodes a bytes stream to a String stream + * + * @param splitOnNewline whether this decoder should split the received data buffers + * along newline characters + */ + public StringDecoder(boolean splitOnNewline) { + super(new MimeType("text", "*", DEFAULT_CHARSET), MimeTypeUtils.ALL); + this.splitOnNewline = splitOnNewline; + } + + + @Override + public boolean canDecode(ResolvableType elementType, MimeType mimeType, Object... hints) { + return super.canDecode(elementType, mimeType, hints) && + String.class.equals(elementType.getRawClass()); + } + + @Override + public Flux decode(Publisher inputStream, ResolvableType elementType, + MimeType mimeType, Object... hints) { + + Flux inputFlux = Flux.from(inputStream); + if (this.splitOnNewline) { + inputFlux = Flux.from(inputStream).flatMap(StringDecoder::splitOnNewline); + } + return inputFlux.map(buffer -> decodeDataBuffer(buffer, mimeType)); + } + + @Override + public Mono decodeToMono(Publisher inputStream, ResolvableType elementType, + MimeType mimeType, Object... hints) { + + return Flux.from(inputStream) + .reduce(DataBuffer::write) + .map(buffer -> decodeDataBuffer(buffer, mimeType)); + } + + private static Flux splitOnNewline(DataBuffer dataBuffer) { + List results = new ArrayList<>(); + int startIdx = 0; + int endIdx; + final int limit = dataBuffer.readableByteCount(); + do { + endIdx = dataBuffer.indexOf(NEWLINE_DELIMITER, startIdx); + int length = endIdx != -1 ? endIdx - startIdx + 1 : limit - startIdx; + DataBuffer token = dataBuffer.slice(startIdx, length); + results.add(DataBufferUtils.retain(token)); + startIdx = endIdx + 1; + } + while (startIdx < limit && endIdx != -1); + DataBufferUtils.release(dataBuffer); + return Flux.fromIterable(results); + } + + private String decodeDataBuffer(DataBuffer dataBuffer, MimeType mimeType) { + Charset charset = getCharset(mimeType); + CharBuffer charBuffer = charset.decode(dataBuffer.asByteBuffer()); + DataBufferUtils.release(dataBuffer); + return charBuffer.toString(); + } + + private Charset getCharset(MimeType mimeType) { + if (mimeType != null && mimeType.getCharset() != null) { + return mimeType.getCharset(); + } + else { + return DEFAULT_CHARSET; + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/core/codec/StringEncoder.java b/spring-web-reactive/src/main/java/org/springframework/core/codec/StringEncoder.java new file mode 100644 index 0000000000..915980f492 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/core/codec/StringEncoder.java @@ -0,0 +1,70 @@ +/* + * Copyright 2002-2016 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.core.codec; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.util.MimeType; + +/** + * Encode from a String stream to a bytes stream. + * + * @author Sebastien Deleuze + * @see StringDecoder + */ +public class StringEncoder extends AbstractEncoder { + + public static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; + + public StringEncoder() { + super(new MimeType("text", "plain", DEFAULT_CHARSET)); + } + + + @Override + public boolean canEncode(ResolvableType elementType, MimeType mimeType, Object... hints) { + Class clazz = elementType.getRawClass(); + return (super.canEncode(elementType, mimeType, hints) && String.class.equals(clazz)); + } + + @Override + public Flux encode(Publisher inputStream, + DataBufferFactory bufferFactory, ResolvableType elementType, MimeType mimeType, + Object... hints) { + Charset charset; + if (mimeType != null && mimeType.getCharset() != null) { + charset = mimeType.getCharset(); + } + else { + charset = DEFAULT_CHARSET; + } + return Flux.from(inputStream).map(s -> { + byte[] bytes = s.getBytes(charset); + DataBuffer dataBuffer = bufferFactory.allocateBuffer(bytes.length); + dataBuffer.write(bytes); + return dataBuffer; + }); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/core/codec/package-info.java b/spring-web-reactive/src/main/java/org/springframework/core/codec/package-info.java new file mode 100644 index 0000000000..5a7d3df6f9 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/core/codec/package-info.java @@ -0,0 +1,6 @@ +/** + * Provides {@link org.springframework.core.codec.Encoder} and + * {@link org.springframework.core.codec.Decoder} abstractions for converting + * to and from between a stream of bytes and a stream of Java objects. + */ +package org.springframework.core.codec; diff --git a/spring-web-reactive/src/main/java/org/springframework/core/convert/support/MonoToCompletableFutureConverter.java b/spring-web-reactive/src/main/java/org/springframework/core/convert/support/MonoToCompletableFutureConverter.java new file mode 100644 index 0000000000..5e4e4453f4 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/core/convert/support/MonoToCompletableFutureConverter.java @@ -0,0 +1,56 @@ +/* + * Copyright 2002-2015 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.core.convert.support; + +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.concurrent.CompletableFuture; + +import org.reactivestreams.Publisher; +import reactor.core.publisher.Mono; + +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.core.convert.converter.GenericConverter; + +/** + * @author Sebastien Deleuze + */ +public class MonoToCompletableFutureConverter implements GenericConverter { + + @Override + public Set getConvertibleTypes() { + Set pairs = new LinkedHashSet<>(); + pairs.add(new GenericConverter.ConvertiblePair(Mono.class, CompletableFuture.class)); + pairs.add(new GenericConverter.ConvertiblePair(CompletableFuture.class, Mono.class)); + return pairs; + } + + @Override + public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { + if (source == null) { + return null; + } + else if (CompletableFuture.class.isAssignableFrom(sourceType.getType())) { + return Mono.fromFuture((CompletableFuture) source); + } + else if (CompletableFuture.class.isAssignableFrom(targetType.getType())) { + return Mono.from((Publisher) source).toFuture(); + } + return null; + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/core/convert/support/ReactorToRxJava1Converter.java b/spring-web-reactive/src/main/java/org/springframework/core/convert/support/ReactorToRxJava1Converter.java new file mode 100644 index 0000000000..42b9d9d809 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/core/convert/support/ReactorToRxJava1Converter.java @@ -0,0 +1,79 @@ +/* + * Copyright 2002-2015 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.core.convert.support; + +import java.util.LinkedHashSet; +import java.util.Set; + +import org.reactivestreams.Publisher; +import reactor.core.converter.RxJava1CompletableConverter; +import reactor.core.converter.RxJava1ObservableConverter; +import reactor.core.converter.RxJava1SingleConverter; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import rx.Completable; +import rx.Observable; +import rx.Single; + +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.core.convert.converter.GenericConverter; + +/** + * @author Stephane Maldini + * @author Sebastien Deleuze + */ +public final class ReactorToRxJava1Converter implements GenericConverter { + + @Override + public Set getConvertibleTypes() { + Set pairs = new LinkedHashSet<>(); + pairs.add(new GenericConverter.ConvertiblePair(Flux.class, Observable.class)); + pairs.add(new GenericConverter.ConvertiblePair(Observable.class, Flux.class)); + pairs.add(new GenericConverter.ConvertiblePair(Mono.class, Single.class)); + pairs.add(new GenericConverter.ConvertiblePair(Single.class, Mono.class)); + pairs.add(new GenericConverter.ConvertiblePair(Mono.class, Completable.class)); + pairs.add(new GenericConverter.ConvertiblePair(Completable.class, Mono.class)); + return pairs; + } + + @Override + public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { + if (source == null) { + return null; + } + if (Observable.class.isAssignableFrom(sourceType.getType())) { + return RxJava1ObservableConverter.toPublisher((Observable) source); + } + else if (Observable.class.isAssignableFrom(targetType.getType())) { + return RxJava1ObservableConverter.fromPublisher((Publisher) source); + } + else if (Single.class.isAssignableFrom(sourceType.getType())) { + return RxJava1SingleConverter.toPublisher((Single) source); + } + else if (Single.class.isAssignableFrom(targetType.getType())) { + return RxJava1SingleConverter.fromPublisher((Publisher) source); + } + else if (Completable.class.isAssignableFrom(sourceType.getType())) { + return RxJava1CompletableConverter.toPublisher((Completable) source); + } + else if (Completable.class.isAssignableFrom(targetType.getType())) { + return RxJava1CompletableConverter.fromPublisher((Publisher) source); + } + return null; + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/core/convert/support/package-info.java b/spring-web-reactive/src/main/java/org/springframework/core/convert/support/package-info.java new file mode 100644 index 0000000000..2a23abd9f0 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/core/convert/support/package-info.java @@ -0,0 +1,4 @@ +/** + * Default implementation of the type conversion system. + */ +package org.springframework.core.convert.support; diff --git a/spring-web-reactive/src/main/java/org/springframework/core/io/buffer/DataBuffer.java b/spring-web-reactive/src/main/java/org/springframework/core/io/buffer/DataBuffer.java new file mode 100644 index 0000000000..822221bdb1 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/core/io/buffer/DataBuffer.java @@ -0,0 +1,163 @@ +/* + * Copyright 2002-2016 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.core.io.buffer; + +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.util.function.IntPredicate; + +/** + * Basic abstraction over byte buffers. + * + * @author Arjen Poutsma + */ +public interface DataBuffer { + + /** + * Returns the {@link DataBufferFactory} that created this buffer. + * @return the creating buffer factory + */ + DataBufferFactory factory(); + + /** + * Returns the index of the first byte in this buffer that matches the given + * predicate. + * @param predicate the predicate to match + * @param fromIndex the index to start the search from + * @return the index of the first byte that matches {@code predicate}; or {@code -1} + * if none match + */ + int indexOf(IntPredicate predicate, int fromIndex); + + /** + * Returns the index of the last byte in this buffer that matches the given + * predicate. + * @param predicate the predicate to match + * @param fromIndex the index to start the search from + * @return the index of the last byte that matches {@code predicate}; or {@code -1} + * if none match + */ + int lastIndexOf(IntPredicate predicate, int fromIndex); + + /** + * Returns the number of bytes that can be read from this data buffer. + * @return the readable byte count + */ + int readableByteCount(); + + /** + * Reads a single byte from the current reading position of this data buffer. + * @return the byte at this buffer's current reading position + */ + byte read(); + + /** + * Reads this buffer's data into the specified destination, starting at the current + * reading position of this buffer. + * + * @param destination the array into which the bytes are to be written + * @return this buffer + */ + DataBuffer read(byte[] destination); + + /** + * Reads at most {@code length} bytes of this buffer into the specified destination, + * starting at the current reading position of this buffer. + * @param destination the array into which the bytes are to be written + * @param offset the index within {@code destination} of the first byte to be written + * @param length the maximum number of bytes to be written in {@code destination} + * @return this buffer + */ + DataBuffer read(byte[] destination, int offset, int length); + + /** + * Write a single byte into this buffer at the current writing position. + * @param b the byte to be written + * @return this buffer + */ + DataBuffer write(byte b); + + /** + * Writes the given source into this buffer, startin at the current writing position + * of this buffer. + * @param source the bytes to be written into this buffer + * @return this buffer + */ + DataBuffer write(byte[] source); + + /** + * Writes at most {@code length} bytes of the given source into this buffer, starting + * at the current writing position of this buffer. + * @param source the bytes to be written into this buffer + * @param offset the index withing {@code source} to start writing from + * @param length the maximum number of bytes to be written from {@code source} + * @return this buffer + */ + DataBuffer write(byte[] source, int offset, int length); + + /** + * Writes one or more {@code DataBuffer}s to this buffer, starting at the current + * writing position. + * @param buffers the byte buffers to write into this buffer + * @return this buffer + */ + DataBuffer write(DataBuffer... buffers); + + /** + * Writes one or more {@link ByteBuffer} to this buffer, starting at the current + * writing position. + * @param buffers the byte buffers to write into this buffer + * @return this buffer + */ + DataBuffer write(ByteBuffer... buffers); + + /** + * Creates a new {@code DataBuffer} whose contents is a shared subsequence of this + * data buffer's content. Data between this data buffer and the returned buffer is + * shared; though changes in the returned buffer's position will not be reflected + * in the reading nor writing position of this data buffer. + * @param index the index at which to start the slice + * @param length the length of the slice + * @return the specified slice of this data buffer + */ + DataBuffer slice(int index, int length); + + /** + * Exposes this buffer's bytes as a {@link ByteBuffer}. Data between this {@code + * DataBuffer} and the returned {@code ByteBuffer} is shared; though changes in the + * returned buffer's {@linkplain ByteBuffer#position() position} will not be reflected + * in the reading nor writing position of this data buffer. + * @return this data buffer as a byte buffer + */ + ByteBuffer asByteBuffer(); + + /** + * Exposes this buffer's data as an {@link InputStream}. Both data and position are + * shared between the returned stream and this data buffer. + * @return this data buffer as an input stream + */ + InputStream asInputStream(); + + /** + * Exposes this buffer's data as an {@link OutputStream}. Both data and position are + * shared between the returned stream and this data buffer. + * @return this data buffer as an output stream + */ + OutputStream asOutputStream(); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/core/io/buffer/DataBufferFactory.java b/spring-web-reactive/src/main/java/org/springframework/core/io/buffer/DataBufferFactory.java new file mode 100644 index 0000000000..9fba5de665 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/core/io/buffer/DataBufferFactory.java @@ -0,0 +1,52 @@ +/* + * Copyright 2002-2016 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.core.io.buffer; + +import java.nio.ByteBuffer; + +/** + * A factory for {@link DataBuffer}s, allowing for allocation and wrapping of data + * buffers. + * + * @author Arjen Poutsma + * @see DataBuffer + */ +public interface DataBufferFactory { + + /** + * Allocates a data buffer of a default initial capacity. Depending on the underlying + * implementation and its configuration, this will be heap-based or direct buffer. + * @return the allocated buffer + */ + DataBuffer allocateBuffer(); + + /** + * Allocates a data buffer of the given initial capacity. Depending on the underlying + * implementation and its configuration, this will be heap-based or direct buffer. + * @param initialCapacity the initial capacity of the buffer to allocateBuffer + * @return the allocated buffer + */ + DataBuffer allocateBuffer(int initialCapacity); + + /** + * Wraps the given {@link ByteBuffer} in a {@code DataBuffer}. + * @param byteBuffer the NIO byte buffer to wrap + * @return the wrapped buffer + */ + DataBuffer wrap(ByteBuffer byteBuffer); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/core/io/buffer/DefaultDataBuffer.java b/spring-web-reactive/src/main/java/org/springframework/core/io/buffer/DefaultDataBuffer.java new file mode 100644 index 0000000000..5b13e7e1a2 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/core/io/buffer/DefaultDataBuffer.java @@ -0,0 +1,361 @@ +/* + * Copyright 2002-2016 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.core.io.buffer; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.function.Function; +import java.util.function.IntPredicate; + +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; + +/** + * Default implementation of the {@link DataBuffer} interface that uses a {@link + * ByteBuffer} internally, with separate read and write positions. Constructed + * using the {@link DefaultDataBufferFactory}. + * + * @author Arjen Poutsma + * @see DefaultDataBufferFactory + */ +public class DefaultDataBuffer implements DataBuffer { + + private final DefaultDataBufferFactory dataBufferFactory; + + private ByteBuffer byteBuffer; + + private int readPosition; + + private int writePosition; + + /** + * Creates a new {@code DefaultDataBuffer} based on the given {@code ByteBuffer}. Both + * reading and writing position of this buffer are based on the current {@linkplain + * ByteBuffer#position() position} of the given buffer. + * @param byteBuffer the buffer to base this buffer on + */ + DefaultDataBuffer(ByteBuffer byteBuffer, DefaultDataBufferFactory dataBufferFactory) { + this(byteBuffer, byteBuffer.position(), byteBuffer.position(), dataBufferFactory); + } + + DefaultDataBuffer(ByteBuffer byteBuffer, int readPosition, int writePosition, + DefaultDataBufferFactory dataBufferFactory) { + Assert.notNull(byteBuffer, "'byteBuffer' must not be null"); + Assert.isTrue(readPosition >= 0, "'readPosition' must be 0 or higher"); + Assert.isTrue(writePosition >= 0, "'writePosition' must be 0 or higher"); + Assert.isTrue(readPosition <= writePosition, + "'readPosition' must be smaller than or equal to 'writePosition'"); + Assert.notNull(dataBufferFactory, "'dataBufferFactory' must not be null"); + + this.byteBuffer = byteBuffer; + this.readPosition = readPosition; + this.writePosition = writePosition; + this.dataBufferFactory = dataBufferFactory; + } + + @Override + public DefaultDataBufferFactory factory() { + return this.dataBufferFactory; + } + + /** + * Directly exposes the native {@code ByteBuffer} that this buffer is based on. + * @return the wrapped byte buffer + */ + public ByteBuffer getNativeBuffer() { + return this.byteBuffer; + } + + @Override + public int indexOf(IntPredicate predicate, int fromIndex) { + Assert.notNull(predicate, "'predicate' must not be null"); + + if (fromIndex < 0) { + fromIndex = 0; + } + else if (fromIndex >= this.writePosition) { + return -1; + } + for (int i = fromIndex; i < this.writePosition; i++) { + byte b = this.byteBuffer.get(i); + if (predicate.test(b)) { + return i; + } + } + return -1; + } + + @Override + public int lastIndexOf(IntPredicate predicate, int fromIndex) { + Assert.notNull(predicate, "'predicate' must not be null"); + int i = Math.min(fromIndex, this.writePosition - 1); + for (; i >= 0; i--) { + byte b = this.byteBuffer.get(i); + if (predicate.test(b)) { + return i; + } + } + return -1; + } + + @Override + public int readableByteCount() { + return this.writePosition - this.readPosition; + } + + @Override + public byte read() { + return readInternal(ByteBuffer::get); + } + + @Override + public DefaultDataBuffer read(byte[] destination) { + Assert.notNull(destination, "'destination' must not be null"); + + readInternal(b -> b.get(destination)); + + return this; + } + + @Override + public DefaultDataBuffer read(byte[] destination, int offset, int length) { + Assert.notNull(destination, "'destination' must not be null"); + + readInternal(b -> b.get(destination, offset, length)); + + return this; + } + + /** + * Internal read method that keeps track of the {@link #readPosition} before and after + * applying the given function on {@link #byteBuffer}. + */ + private T readInternal(Function function) { + this.byteBuffer.position(this.readPosition); + try { + return function.apply(this.byteBuffer); + } + finally { + this.readPosition = this.byteBuffer.position(); + } + } + + @Override + public DefaultDataBuffer write(byte b) { + ensureExtraCapacity(1); + writeInternal(buffer -> buffer.put(b)); + + return this; + } + + @Override + public DefaultDataBuffer write(byte[] source) { + Assert.notNull(source, "'source' must not be null"); + + ensureExtraCapacity(source.length); + writeInternal(buffer -> buffer.put(source)); + return this; + } + + @Override + public DefaultDataBuffer write(byte[] source, int offset, int length) { + Assert.notNull(source, "'source' must not be null"); + + ensureExtraCapacity(length); + writeInternal(buffer -> buffer.put(source, offset, length)); + return this; + } + + @Override + public DataBuffer write(DataBuffer... buffers) { + if (!ObjectUtils.isEmpty(buffers)) { + ByteBuffer[] byteBuffers = + Arrays.stream(buffers).map(DataBuffer::asByteBuffer) + .toArray(ByteBuffer[]::new); + write(byteBuffers); + } + return this; + } + + @Override + public DefaultDataBuffer write(ByteBuffer... byteBuffers) { + Assert.notEmpty(byteBuffers, "'byteBuffers' must not be empty"); + + int extraCapacity = + Arrays.stream(byteBuffers).mapToInt(ByteBuffer::remaining).sum(); + + ensureExtraCapacity(extraCapacity); + + Arrays.stream(byteBuffers) + .forEach(byteBuffer -> writeInternal(buffer -> buffer.put(byteBuffer))); + + return this; + } + + /** + * Internal write method that keeps track of the {@link #writePosition} before and + * after applying the given function on {@link #byteBuffer}. + */ + private T writeInternal(Function function) { + this.byteBuffer.position(this.writePosition); + try { + return function.apply(this.byteBuffer); + } + finally { + this.writePosition = this.byteBuffer.position(); + } + } + + @Override + public DataBuffer slice(int index, int length) { + int oldPosition = this.byteBuffer.position(); + try { + this.byteBuffer.position(index); + ByteBuffer slice = this.byteBuffer.slice(); + slice.limit(length); + return new SlicedDefaultDataBuffer(slice, 0, length, this.dataBufferFactory); + } + finally { + this.byteBuffer.position(oldPosition); + } + + } + + @Override + public ByteBuffer asByteBuffer() { + ByteBuffer duplicate = this.byteBuffer.duplicate(); + duplicate.position(this.readPosition); + duplicate.limit(this.writePosition); + return duplicate; + } + + @Override + public InputStream asInputStream() { + return new DefaultDataBufferInputStream(); + } + + @Override + public OutputStream asOutputStream() { + return new DefaultDataBufferOutputStream(); + } + + private void ensureExtraCapacity(int extraCapacity) { + int neededCapacity = this.writePosition + extraCapacity; + if (neededCapacity > this.byteBuffer.capacity()) { + grow(neededCapacity); + } + } + + void grow(int minCapacity) { + ByteBuffer oldBuffer = this.byteBuffer; + ByteBuffer newBuffer = + (oldBuffer.isDirect() ? ByteBuffer.allocateDirect(minCapacity) : + ByteBuffer.allocate(minCapacity)); + + oldBuffer.position(this.readPosition); + newBuffer.put(oldBuffer); + + this.byteBuffer = newBuffer; + oldBuffer.clear(); + } + + + @Override + public int hashCode() { + return this.byteBuffer.hashCode(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + else if (obj instanceof DefaultDataBuffer) { + DefaultDataBuffer other = (DefaultDataBuffer) obj; + return this.readPosition == other.readPosition && + this.writePosition == other.writePosition && + this.byteBuffer.equals(other.byteBuffer); + } + return false; + } + + @Override + public String toString() { + return this.byteBuffer.toString(); + } + + private class DefaultDataBufferInputStream extends InputStream { + + @Override + public int available() throws IOException { + return readableByteCount(); + } + + @Override + public int read() { + return readInternal( + buffer -> readableByteCount() > 0 ? buffer.get() & 0xFF : -1); + } + + @Override + public int read(byte[] bytes, int off, int len) throws IOException { + return readInternal(buffer -> { + int count = readableByteCount(); + if (count > 0) { + int minLen = Math.min(len, count); + buffer.get(bytes, off, minLen); + return minLen; + } + else { + return -1; + } + }); + } + } + + private class DefaultDataBufferOutputStream extends OutputStream { + + @Override + public void write(int b) throws IOException { + ensureExtraCapacity(1); + writeInternal(buffer -> buffer.put((byte) b)); + } + + @Override + public void write(byte[] bytes, int off, int len) throws IOException { + ensureExtraCapacity(len); + writeInternal(buffer -> buffer.put(bytes, off, len)); + } + } + + private static class SlicedDefaultDataBuffer extends DefaultDataBuffer { + + SlicedDefaultDataBuffer(ByteBuffer byteBuffer, int readPosition, + int writePosition, DefaultDataBufferFactory dataBufferFactory) { + super(byteBuffer, readPosition, writePosition, dataBufferFactory); + } + + @Override + void grow(int minCapacity) { + throw new UnsupportedOperationException( + "Growing the capacity of a sliced buffer is not supported"); + } + } +} diff --git a/spring-web-reactive/src/main/java/org/springframework/core/io/buffer/DefaultDataBufferFactory.java b/spring-web-reactive/src/main/java/org/springframework/core/io/buffer/DefaultDataBufferFactory.java new file mode 100644 index 0000000000..7b1dd007b8 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/core/io/buffer/DefaultDataBufferFactory.java @@ -0,0 +1,98 @@ +/* + * Copyright 2002-2016 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.core.io.buffer; + +import java.nio.ByteBuffer; + +import org.springframework.util.Assert; + +/** + * Default implementation of the {@code DataBufferFactory} interface. Allows for + * specification of the default initial capacity at construction time, as well as whether + * heap-based or direct buffers are to be preferred. + * + * @author Arjen Poutsma + */ +public class DefaultDataBufferFactory implements DataBufferFactory { + + /** + * The default capacity when none is specified. + * @see #DefaultDataBufferFactory() + * @see #DefaultDataBufferFactory(boolean) + */ + public static final int DEFAULT_INITIAL_CAPACITY = 256; + + + private final boolean preferDirect; + + private final int defaultInitialCapacity; + + /** + * Creates a new {@code DefaultDataBufferFactory} with default settings. + */ + public DefaultDataBufferFactory() { + this(false); + } + + /** + * Creates a new {@code DefaultDataBufferFactory}, indicating whether direct buffers + * should be created by {@link #allocateBuffer()} and {@link #allocateBuffer(int)}. + * @param preferDirect {@code true} if direct buffers are to be preferred; {@code + * false} otherwise + */ + public DefaultDataBufferFactory(boolean preferDirect) { + this(preferDirect, DEFAULT_INITIAL_CAPACITY); + } + + /** + * Creates a new {@code DefaultDataBufferFactory}, indicating whether direct buffers + * should be created by {@link #allocateBuffer()} and {@link #allocateBuffer(int)}, + * and what the capacity is to be used for {@link #allocateBuffer()}. + * @param preferDirect {@code true} if direct buffers are to be preferred; {@code + * false} otherwise + */ + public DefaultDataBufferFactory(boolean preferDirect, int defaultInitialCapacity) { + Assert.isTrue(defaultInitialCapacity > 0, + "'defaultInitialCapacity' should be larger than 0"); + this.preferDirect = preferDirect; + this.defaultInitialCapacity = defaultInitialCapacity; + } + + @Override + public DefaultDataBuffer allocateBuffer() { + return allocateBuffer(this.defaultInitialCapacity); + } + + @Override + public DefaultDataBuffer allocateBuffer(int initialCapacity) { + return this.preferDirect ? + new DefaultDataBuffer(ByteBuffer.allocateDirect(initialCapacity), this) : + new DefaultDataBuffer(ByteBuffer.allocate(initialCapacity), this); + } + + @Override + public DefaultDataBuffer wrap(ByteBuffer byteBuffer) { + ByteBuffer sliced = byteBuffer.slice(); + return new DefaultDataBuffer(sliced, 0, byteBuffer.remaining(), this); + } + + @Override + public String toString() { + return "DefaultDataBufferFactory - preferDirect: " + this.preferDirect; + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/core/io/buffer/FlushingDataBuffer.java b/spring-web-reactive/src/main/java/org/springframework/core/io/buffer/FlushingDataBuffer.java new file mode 100644 index 0000000000..3d77bd7f48 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/core/io/buffer/FlushingDataBuffer.java @@ -0,0 +1,124 @@ +/* + * Copyright 2002-2016 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.core.io.buffer; + +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.util.function.IntPredicate; + +/** + * Empty {@link DataBuffer} that indicates to the file or the socket writing it that + * previously buffered data should be flushed. + * + * @author Sebastien Deleuze + * @see FlushingDataBuffer#INSTANCE + */ +public class FlushingDataBuffer implements DataBuffer { + + /** Singleton instance of this class */ + public static final FlushingDataBuffer INSTANCE = new FlushingDataBuffer(); + + private final DataBuffer buffer; + + + private FlushingDataBuffer() { + this.buffer = new DefaultDataBufferFactory().allocateBuffer(0); + } + + + @Override + public DataBufferFactory factory() { + return this.buffer.factory(); + } + + @Override + public int indexOf(IntPredicate predicate, int fromIndex) { + return this.buffer.indexOf(predicate, fromIndex); + } + + @Override + public int lastIndexOf(IntPredicate predicate, int fromIndex) { + return this.buffer.lastIndexOf(predicate, fromIndex); + } + + @Override + public int readableByteCount() { + return this.buffer.readableByteCount(); + } + + @Override + public byte read() { + return this.buffer.read(); + } + + @Override + public DataBuffer read(byte[] destination) { + return this.buffer.read(destination); + } + + @Override + public DataBuffer read(byte[] destination, int offset, int length) { + return this.buffer.read(destination, offset, length); + } + + @Override + public DataBuffer write(byte b) { + return this.buffer.write(b); + } + + @Override + public DataBuffer write(byte[] source) { + return this.buffer.write(source); + } + + @Override + public DataBuffer write(byte[] source, int offset, int length) { + return this.buffer.write(source, offset, length); + } + + @Override + public DataBuffer write(DataBuffer... buffers) { + return this.buffer.write(buffers); + } + + @Override + public DataBuffer write(ByteBuffer... buffers) { + return this.buffer.write(buffers); + } + + @Override + public DataBuffer slice(int index, int length) { + return this.buffer.slice(index, length); + } + + @Override + public ByteBuffer asByteBuffer() { + return this.buffer.asByteBuffer(); + } + + @Override + public InputStream asInputStream() { + return this.buffer.asInputStream(); + } + + @Override + public OutputStream asOutputStream() { + return this.buffer.asOutputStream(); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/core/io/buffer/NettyDataBuffer.java b/spring-web-reactive/src/main/java/org/springframework/core/io/buffer/NettyDataBuffer.java new file mode 100644 index 0000000000..628f7bec66 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/core/io/buffer/NettyDataBuffer.java @@ -0,0 +1,242 @@ +/* + * Copyright 2002-2016 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.core.io.buffer; + +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.function.IntPredicate; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufInputStream; +import io.netty.buffer.ByteBufOutputStream; +import io.netty.buffer.CompositeByteBuf; +import io.netty.buffer.Unpooled; + +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; + +/** + * Implementation of the {@code DataBuffer} interface that wraps a Netty {@link ByteBuf}. + * Typically constructed using the {@link NettyDataBufferFactory}. + * + * @author Arjen Poutsma + */ +public class NettyDataBuffer implements PooledDataBuffer { + + private final NettyDataBufferFactory dataBufferFactory; + + private ByteBuf byteBuf; + + /** + * Creates a new {@code NettyDataBuffer} based on the given {@code ByteBuff}. + * @param byteBuf the buffer to base this buffer on + */ + NettyDataBuffer(ByteBuf byteBuf, NettyDataBufferFactory dataBufferFactory) { + Assert.notNull(byteBuf, "'byteBuf' must not be null"); + Assert.notNull(dataBufferFactory, "'dataBufferFactory' must not be null"); + + this.byteBuf = byteBuf; + this.dataBufferFactory = dataBufferFactory; + } + + @Override + public NettyDataBufferFactory factory() { + return this.dataBufferFactory; + } + + /** + * Directly exposes the native {@code ByteBuf} that this buffer is based on. + * @return the wrapped byte buffer + */ + public ByteBuf getNativeBuffer() { + return this.byteBuf; + } + + @Override + public int indexOf(IntPredicate predicate, int fromIndex) { + Assert.notNull(predicate, "'predicate' must not be null"); + if (fromIndex < 0) { + fromIndex = 0; + } + else if (fromIndex >= this.byteBuf.writerIndex()) { + return -1; + } + int length = this.byteBuf.writerIndex() - fromIndex; + + return this.byteBuf.forEachByte(fromIndex, length, predicate.negate()::test); + } + + @Override + public int lastIndexOf(IntPredicate predicate, int fromIndex) { + Assert.notNull(predicate, "'predicate' must not be null"); + if (fromIndex < 0) { + return -1; + } + fromIndex = Math.min(fromIndex, this.byteBuf.writerIndex() - 1); + + return this.byteBuf.forEachByteDesc(0, fromIndex, predicate.negate()::test); + } + + @Override + public int readableByteCount() { + return this.byteBuf.readableBytes(); + } + + @Override + public byte read() { + return this.byteBuf.readByte(); + } + + @Override + public NettyDataBuffer read(byte[] destination) { + this.byteBuf.readBytes(destination); + return this; + } + + @Override + public NettyDataBuffer read(byte[] destination, int offset, int length) { + this.byteBuf.readBytes(destination, offset, length); + return this; + } + + @Override + public NettyDataBuffer write(byte b) { + this.byteBuf.writeByte(b); + return this; + } + + @Override + public NettyDataBuffer write(byte[] source) { + this.byteBuf.writeBytes(source); + return this; + } + + @Override + public NettyDataBuffer write(byte[] source, int offset, int length) { + this.byteBuf.writeBytes(source, offset, length); + return this; + } + + @Override + public NettyDataBuffer write(DataBuffer... buffers) { + if (!ObjectUtils.isEmpty(buffers)) { + if (buffers[0] instanceof NettyDataBuffer) { + ByteBuf[] nativeBuffers = Arrays.stream(buffers) + .map(b -> ((NettyDataBuffer) b).getNativeBuffer()) + .toArray(ByteBuf[]::new); + + write(nativeBuffers); + } + else { + ByteBuffer[] byteBuffers = + Arrays.stream(buffers).map(DataBuffer::asByteBuffer) + .toArray(ByteBuffer[]::new); + write(byteBuffers); + } + } + return this; + } + + @Override + public NettyDataBuffer write(ByteBuffer... buffers) { + Assert.notNull(buffers, "'buffers' must not be null"); + + ByteBuf[] wrappedBuffers = Arrays.stream(buffers).map(Unpooled::wrappedBuffer) + .toArray(ByteBuf[]::new); + + return write(wrappedBuffers); + } + + /** + * Writes one or more Netty {@link ByteBuf}s to this buffer, starting at the current + * writing position. + * @param byteBufs the buffers to write into this buffer + * @return this buffer + */ + public NettyDataBuffer write(ByteBuf... byteBufs) { + Assert.notNull(byteBufs, "'byteBufs' must not be null"); + + CompositeByteBuf composite = + new CompositeByteBuf(this.byteBuf.alloc(), this.byteBuf.isDirect(), + byteBufs.length + 1); + composite.addComponent(this.byteBuf); + composite.addComponents(byteBufs); + + int writerIndex = this.byteBuf.readableBytes() + + Arrays.stream(byteBufs).mapToInt(ByteBuf::readableBytes).sum(); + composite.writerIndex(writerIndex); + + this.byteBuf = composite; + + return this; + } + + @Override + public DataBuffer slice(int index, int length) { + ByteBuf slice = this.byteBuf.slice(index, length); + return new NettyDataBuffer(slice, this.dataBufferFactory); + } + + @Override + public ByteBuffer asByteBuffer() { + return this.byteBuf.nioBuffer(); + } + + @Override + public InputStream asInputStream() { + return new ByteBufInputStream(this.byteBuf); + } + + @Override + public OutputStream asOutputStream() { + return new ByteBufOutputStream(this.byteBuf); + } + + @Override + public PooledDataBuffer retain() { + return new NettyDataBuffer(this.byteBuf.retain(), dataBufferFactory); + } + + @Override + public boolean release() { + return this.byteBuf.release(); + } + + @Override + public int hashCode() { + return this.byteBuf.hashCode(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + else if (obj instanceof NettyDataBuffer) { + NettyDataBuffer other = (NettyDataBuffer) obj; + return this.byteBuf.equals(other.byteBuf); + } + return false; + } + + @Override + public String toString() { + return this.byteBuf.toString(); + } +} diff --git a/spring-web-reactive/src/main/java/org/springframework/core/io/buffer/NettyDataBufferFactory.java b/spring-web-reactive/src/main/java/org/springframework/core/io/buffer/NettyDataBufferFactory.java new file mode 100644 index 0000000000..440c2c0f4b --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/core/io/buffer/NettyDataBufferFactory.java @@ -0,0 +1,82 @@ +/* + * Copyright 2002-2016 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.core.io.buffer; + +import java.nio.ByteBuffer; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufAllocator; +import io.netty.buffer.Unpooled; + +import org.springframework.util.Assert; + +/** + * Implementation of the {@code DataBufferFactory} interface based on a Netty + * {@link ByteBufAllocator}. + * + * @author Arjen Poutsma + * @see io.netty.buffer.PooledByteBufAllocator + * @see io.netty.buffer.UnpooledByteBufAllocator + */ +public class NettyDataBufferFactory implements DataBufferFactory { + + private final ByteBufAllocator byteBufAllocator; + + /** + * Creates a new {@code NettyDataBufferFactory} based on the given factory. + * @param byteBufAllocator the factory to use + * @see io.netty.buffer.PooledByteBufAllocator + * @see io.netty.buffer.UnpooledByteBufAllocator + */ + public NettyDataBufferFactory(ByteBufAllocator byteBufAllocator) { + Assert.notNull(byteBufAllocator, "'byteBufAllocator' must not be null"); + + this.byteBufAllocator = byteBufAllocator; + } + + @Override + public NettyDataBuffer allocateBuffer() { + ByteBuf byteBuf = this.byteBufAllocator.buffer(); + return new NettyDataBuffer(byteBuf, this); + } + + @Override + public NettyDataBuffer allocateBuffer(int initialCapacity) { + ByteBuf byteBuf = this.byteBufAllocator.buffer(initialCapacity); + return new NettyDataBuffer(byteBuf, this); + } + + @Override + public NettyDataBuffer wrap(ByteBuffer byteBuffer) { + ByteBuf byteBuf = Unpooled.wrappedBuffer(byteBuffer); + return new NettyDataBuffer(byteBuf, this); + } + + /** + * Wraps the given Netty {@link ByteBuf} in a {@code NettyDataBuffer}. + * @param byteBuf the Netty byte buffer to wrap + * @return the wrapped buffer + */ + public NettyDataBuffer wrap(ByteBuf byteBuf) { + return new NettyDataBuffer(byteBuf, this); + } + + @Override + public String toString() { + return "NettyDataBufferFactory (" + this.byteBufAllocator + ")"; + } +} diff --git a/spring-web-reactive/src/main/java/org/springframework/core/io/buffer/PooledDataBuffer.java b/spring-web-reactive/src/main/java/org/springframework/core/io/buffer/PooledDataBuffer.java new file mode 100644 index 0000000000..ae2b2f7ca0 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/core/io/buffer/PooledDataBuffer.java @@ -0,0 +1,40 @@ +/* + * Copyright 2002-2016 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.core.io.buffer; + +/** + * Extension of {@link DataBuffer} that allows for buffer that share a memory pool. + * Introduces methods for reference counting. + * + * @author Arjen Poutsma + */ +public interface PooledDataBuffer extends DataBuffer { + + /** + * Increases the reference count for this buffer by one. + * @return this buffer + */ + PooledDataBuffer retain(); + + /** + * Decreases the reference count for this buffer by one, and releases it once the + * count reaches zero. + * @return {@code true} if the buffer was released; {@code false} otherwise. + */ + boolean release(); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/core/io/buffer/support/DataBufferUtils.java b/spring-web-reactive/src/main/java/org/springframework/core/io/buffer/support/DataBufferUtils.java new file mode 100644 index 0000000000..249eb1146a --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/core/io/buffer/support/DataBufferUtils.java @@ -0,0 +1,196 @@ +/* + * Copyright 2002-2016 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.core.io.buffer.support; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.channels.Channels; +import java.nio.channels.ReadableByteChannel; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.BiFunction; +import java.util.function.Consumer; + +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.subscriber.SignalEmitter; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.io.buffer.PooledDataBuffer; +import org.springframework.util.Assert; + +/**i + * Utility class for working with {@link DataBuffer}s. + * + * @author Arjen Poutsma + */ +public abstract class DataBufferUtils { + + private static final Consumer CLOSE_CONSUMER = channel -> { + try { + if (channel != null) { + channel.close(); + } + } + catch (IOException ignored) { + } + }; + + /** + * Reads the given {@code InputStream} into a {@code Flux} of + * {@code DataBuffer}s. Closes the input stream when the flux is terminated. + * @param inputStream the input stream to read from + * @param dataBufferFactory the factory to create data buffers with + * @param bufferSize the maximum size of the data buffers + * @return a flux of data buffers read from the given channel + */ + public static Flux read(InputStream inputStream, + DataBufferFactory dataBufferFactory, int bufferSize) { + Assert.notNull(inputStream, "'inputStream' must not be null"); + Assert.notNull(dataBufferFactory, "'dataBufferFactory' must not be null"); + + ReadableByteChannel channel = Channels.newChannel(inputStream); + return read(channel, dataBufferFactory, bufferSize); + } + + /** + * Reads the given {@code ReadableByteChannel} into a {@code Flux} of + * {@code DataBuffer}s. Closes the channel when the flux is terminated. + * @param channel the channel to read from + * @param dataBufferFactory the factory to create data buffers with + * @param bufferSize the maximum size of the data buffers + * @return a flux of data buffers read from the given channel + */ + public static Flux read(ReadableByteChannel channel, + DataBufferFactory dataBufferFactory, int bufferSize) { + Assert.notNull(channel, "'channel' must not be null"); + Assert.notNull(dataBufferFactory, "'dataBufferFactory' must not be null"); + + return Flux.generate(() -> channel, + new ReadableByteChannelGenerator(dataBufferFactory, bufferSize), + CLOSE_CONSUMER); + } + + /** + * Relays buffers from the given {@link Publisher} until the total + * {@linkplain DataBuffer#readableByteCount() byte count} reaches the given maximum + * byte count, or until the publisher is complete. + * @param publisher the publisher to filter + * @param maxByteCount the maximum byte count + * @return a flux whose maximum byte count is {@code maxByteCount} + */ + public static Flux takeUntilByteCount(Publisher publisher, + long maxByteCount) { + Assert.notNull(publisher, "'publisher' must not be null"); + Assert.isTrue(maxByteCount >= 0, "'maxByteCount' must be a positive number"); + + AtomicLong byteCountDown = new AtomicLong(maxByteCount); + + return Flux.from(publisher). + takeWhile(dataBuffer -> { + int delta = -dataBuffer.readableByteCount(); + long currentCount = byteCountDown.getAndAdd(delta); + return currentCount >= 0; + }). + map(dataBuffer -> { + long currentCount = byteCountDown.get(); + if (currentCount >= 0) { + return dataBuffer; + } + else { + // last buffer + int size = (int) (currentCount + dataBuffer.readableByteCount()); + return dataBuffer.slice(0, size); + } + }); + } + + /** + * Retains the given data buffer, it it is a {@link PooledDataBuffer}. + * @param dataBuffer the data buffer to retain + * @return the retained buffer + */ + @SuppressWarnings("unchecked") + public static T retain(T dataBuffer) { + if (dataBuffer instanceof PooledDataBuffer) { + return (T) ((PooledDataBuffer) dataBuffer).retain(); + } + else { + return dataBuffer; + } + } + + /** + * Releases the given data buffer, if it is a {@link PooledDataBuffer}. + * @param dataBuffer the data buffer to release + * @return {@code true} if the buffer was released; {@code false} otherwise. + */ + public static boolean release(DataBuffer dataBuffer) { + if (dataBuffer instanceof PooledDataBuffer) { + return ((PooledDataBuffer) dataBuffer).release(); + } + return false; + } + + private static class ReadableByteChannelGenerator + implements BiFunction, + ReadableByteChannel> { + + private final DataBufferFactory dataBufferFactory; + + private final int chunkSize; + + public ReadableByteChannelGenerator(DataBufferFactory dataBufferFactory, + int chunkSize) { + this.dataBufferFactory = dataBufferFactory; + this.chunkSize = chunkSize; + } + + @Override + public ReadableByteChannel apply(ReadableByteChannel + channel, SignalEmitter sub) { + try { + ByteBuffer byteBuffer = ByteBuffer.allocate(chunkSize); + int read; + if ((read = channel.read(byteBuffer)) > 0) { + byteBuffer.flip(); + boolean release = true; + DataBuffer dataBuffer = this.dataBufferFactory.allocateBuffer(read); + try { + dataBuffer.write(byteBuffer); + release = false; + sub.next(dataBuffer); + } + finally { + if (release) { + release(dataBuffer); + } + } + } + else { + sub.complete(); + } + } + catch (IOException ex) { + sub.fail(ex); + } + return channel; + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/core/io/support/ResourceUtils2.java b/spring-web-reactive/src/main/java/org/springframework/core/io/support/ResourceUtils2.java new file mode 100644 index 0000000000..94a3af3f14 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/core/io/support/ResourceUtils2.java @@ -0,0 +1,59 @@ +/* + * Copyright 2002-2016 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.core.io.support; + +import java.io.IOException; +import java.net.URI; + +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.DescriptiveResource; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; +import org.springframework.util.Assert; +import org.springframework.util.ResourceUtils; + +/** + * @author Arjen Poutsma + */ +public abstract class ResourceUtils2 { + + /** + * Indicates whether the given resource has a file, so that {@link + * Resource#getFile()} + * can be called without an {@link java.io.IOException}. + * @param resource the resource to check + * @return {@code true} if the given resource has a file; {@code false} otherwise + */ + // TODO: refactor into Resource.hasFile() method + public static boolean hasFile(Resource resource) { + Assert.notNull(resource, "'resource' must not be null"); + + // the following Resource implementations do not support getURI/getFile + if (resource instanceof ByteArrayResource || + resource instanceof DescriptiveResource || + resource instanceof InputStreamResource) { + return false; + } + try { + URI resourceUri = resource.getURI(); + return ResourceUtils.URL_PROTOCOL_FILE.equals(resourceUri.getScheme()); + } + catch (IOException ignored) { + } + return false; + } +} diff --git a/spring-web-reactive/src/main/java/org/springframework/http/HttpCookie.java b/spring-web-reactive/src/main/java/org/springframework/http/HttpCookie.java new file mode 100644 index 0000000000..34f57ae8be --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/HttpCookie.java @@ -0,0 +1,73 @@ +/* + * Copyright 2002-2015 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; + +/** + * Represents an HTTP cookie as a name-value pair consistent with the content of + * the "Cookie" request header. The {@link ResponseCookie} sub-class has the + * additional attributes expected in the "Set-Cookie" response header. + * + * @author Rossen Stoyanchev + * @see RFC 6265 + */ +public class HttpCookie { + + private final String name; + + private final String value; + + + public HttpCookie(String name, String value) { + Assert.hasLength(name, "'name' is required and must not be empty."); + this.name = name; + this.value = (value != null ? value : ""); + } + + /** + * Return the cookie name. + */ + public String getName() { + return this.name; + } + + /** + * Return the cookie value or an empty string, never {@code null}. + */ + public String getValue() { + return this.value; + } + + + @Override + public int hashCode() { + return this.name.hashCode(); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof HttpCookie)) { + return false; + } + HttpCookie otherCookie = (HttpCookie) other; + return (this.name.equalsIgnoreCase(otherCookie.getName())); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/http/ReactiveHttpInputMessage.java b/spring-web-reactive/src/main/java/org/springframework/http/ReactiveHttpInputMessage.java new file mode 100644 index 0000000000..15e4f041dc --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/ReactiveHttpInputMessage.java @@ -0,0 +1,40 @@ +/* + * Copyright 2002-2016 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.reactivestreams.Publisher; +import reactor.core.publisher.Flux; + +import org.springframework.core.io.buffer.DataBuffer; + +/** + * An "reactive" HTTP input message that exposes the input as {@link Publisher}. + * + *

Typically implemented by an HTTP request on the server-side or a response + * on the client-side. + * + * @author Arjen Poutsma + */ +public interface ReactiveHttpInputMessage extends HttpMessage { + + /** + * Return the body of the message as a {@link Publisher}. + * @return the body content publisher + */ + Flux getBody(); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/http/ReactiveHttpOutputMessage.java b/spring-web-reactive/src/main/java/org/springframework/http/ReactiveHttpOutputMessage.java new file mode 100644 index 0000000000..1c6a4af051 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/ReactiveHttpOutputMessage.java @@ -0,0 +1,76 @@ +/* + * Copyright 2002-2016 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.function.Supplier; + +import org.reactivestreams.Publisher; +import reactor.core.publisher.Mono; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.io.buffer.FlushingDataBuffer; + +/** + * A "reactive" HTTP output message that accepts output as a {@link Publisher}. + * + *

Typically implemented by an HTTP request on the client-side or a response + * on the server-side. + * + * @author Arjen Poutsma + * @author Sebastien Deleuze + */ +public interface ReactiveHttpOutputMessage extends HttpMessage { + + /** + * Register an action to be applied just before the message is committed. + * @param action the action + */ + void beforeCommit(Supplier> action); + + /** + * Use the given {@link Publisher} to write the body of the message to the underlying + * HTTP layer, and flush the data when the complete signal is received (data could be + * flushed before depending on the configuration, the HTTP engine and the amount of + * data sent). + * + *

Each {@link FlushingDataBuffer} element will trigger a flush. + * + * @param body the body content publisher + * @return a publisher that indicates completion or error. + */ + Mono writeWith(Publisher body); + + /** + * Returns a {@link DataBufferFactory} that can be used for creating the body. + * @return a buffer factory + * @see #writeWith(Publisher) + */ + DataBufferFactory bufferFactory(); + + /** + * Indicate that message handling is complete, allowing for any cleanup or + * end-of-processing tasks to be performed such as applying header changes + * made via {@link #getHeaders()} to the underlying HTTP message (if not + * applied already). + *

This method should be automatically invoked at the end of message + * processing so typically applications should not have to invoke it. + * If invoked multiple times it should have no side effects. + */ + Mono setComplete(); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/http/ResponseCookie.java b/spring-web-reactive/src/main/java/org/springframework/http/ResponseCookie.java new file mode 100644 index 0000000000..0254d5a435 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/ResponseCookie.java @@ -0,0 +1,238 @@ +/* + * Copyright 2002-2015 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.time.Duration; +import java.util.Optional; + +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; + +/** + * An {@code HttpCookie} sub-class with the additional attributes allowed in + * the "Set-Cookie" response header. To build an instance use the {@link #from} + * static method. + * + * @author Rossen Stoyanchev + * @see RFC 6265 + */ +public final class ResponseCookie extends HttpCookie { + + private final Duration maxAge; + + private final Optional domain; + + private final Optional path; + + private final boolean secure; + + private final boolean httpOnly; + + + /** + * Private constructor. See {@link #from(String, String)}. + */ + private ResponseCookie(String name, String value, Duration maxAge, String domain, + String path, boolean secure, boolean httpOnly) { + + super(name, value); + Assert.notNull(maxAge); + this.maxAge = maxAge; + this.domain = Optional.ofNullable(domain); + this.path = Optional.ofNullable(path); + this.secure = secure; + this.httpOnly = httpOnly; + } + + + /** + * Return the cookie "Max-Age" attribute in seconds. + * + *

A positive value indicates when the cookie expires relative to the + * current time. A value of 0 means the cookie should expire immediately. + * A negative value means no "Max-Age" attribute in which case the cookie + * is removed when the browser is closed. + */ + public Duration getMaxAge() { + return this.maxAge; + } + + /** + * Return the cookie "Domain" attribute. + */ + public Optional getDomain() { + return this.domain; + } + + /** + * Return the cookie "Path" attribute. + */ + public Optional getPath() { + return this.path; + } + + /** + * Return {@code true} if the cookie has the "Secure" attribute. + */ + public boolean isSecure() { + return this.secure; + } + + /** + * Return {@code true} if the cookie has the "HttpOnly" attribute. + * @see http://www.owasp.org/index.php/HTTPOnly + */ + public boolean isHttpOnly() { + return this.httpOnly; + } + + @Override + public int hashCode() { + int result = super.hashCode(); + result = 31 * result + ObjectUtils.nullSafeHashCode(this.domain); + result = 31 * result + ObjectUtils.nullSafeHashCode(this.path); + return result; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof ResponseCookie)) { + return false; + } + ResponseCookie otherCookie = (ResponseCookie) other; + return (getName().equalsIgnoreCase(otherCookie.getName()) && + ObjectUtils.nullSafeEquals(this.path, otherCookie.getPath()) && + ObjectUtils.nullSafeEquals(this.domain, otherCookie.getDomain())); + } + + + /** + * Factory method to obtain a builder for a server-defined cookie that starts + * with a name-value pair and may also include attributes. + * @param name the cookie name + * @param value the cookie value + * @return the created cookie instance + */ + public static ResponseCookieBuilder from(final String name, final String value) { + + return new ResponseCookieBuilder() { + + private Duration maxAge = Duration.ofSeconds(-1); + + private String domain; + + private String path; + + private boolean secure; + + private boolean httpOnly; + + + @Override + public ResponseCookieBuilder maxAge(Duration maxAge) { + this.maxAge = maxAge; + return this; + } + + @Override + public ResponseCookieBuilder maxAge(long maxAgeSeconds) { + this.maxAge = maxAgeSeconds >= 0 ? Duration.ofSeconds(maxAgeSeconds) : Duration.ofSeconds(-1); + return this; + } + + @Override + public ResponseCookieBuilder domain(String domain) { + this.domain = domain; + return this; + } + + @Override + public ResponseCookieBuilder path(String path) { + this.path = path; + return this; + } + + @Override + public ResponseCookieBuilder secure(boolean secure) { + this.secure = secure; + return this; + } + + @Override + public ResponseCookieBuilder httpOnly(boolean httpOnly) { + this.httpOnly = httpOnly; + return this; + } + + @Override + public ResponseCookie build() { + return new ResponseCookie(name, value, this.maxAge, this.domain, this.path, + this.secure, this.httpOnly); + } + }; + } + + /** + * A builder for a server-defined HttpCookie with attributes. + */ + public interface ResponseCookieBuilder { + + /** + * Set the cookie "Max-Age" attribute. + * + *

A positive value indicates when the cookie should expire relative + * to the current time. A value of 0 means the cookie should expire + * immediately. A negative value results in no "Max-Age" attribute in + * which case the cookie is removed when the browser is closed. + */ + ResponseCookieBuilder maxAge(Duration maxAge); + + /** + * Set the cookie "Max-Age" attribute in seconds. + */ + ResponseCookieBuilder maxAge(long maxAgeSeconds); + + /** + * Set the cookie "Path" attribute. + */ + ResponseCookieBuilder path(String path); + + /** + * Set the cookie "Domain" attribute. + */ + ResponseCookieBuilder domain(String domain); + + /** + * Add the "Secure" attribute to the cookie. + */ + ResponseCookieBuilder secure(boolean secure); + + /** + * Add the "HttpOnly" attribute to the cookie. + * @see http://www.owasp.org/index.php/HTTPOnly + */ + ResponseCookieBuilder httpOnly(boolean httpOnly); + + /** + * Create the HttpCookie. + */ + ResponseCookie build(); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/http/ZeroCopyHttpOutputMessage.java b/spring-web-reactive/src/main/java/org/springframework/http/ZeroCopyHttpOutputMessage.java new file mode 100644 index 0000000000..91df2452e8 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/ZeroCopyHttpOutputMessage.java @@ -0,0 +1,42 @@ +/* + * Copyright 2002-2016 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.io.File; + +import reactor.core.publisher.Mono; + +/** + * Sub-interface of {@code ReactiveOutputMessage} that has support for "zero-copy" + * file transfers. + * + * @author Arjen Poutsma + * @see Zero-copy + */ +public interface ZeroCopyHttpOutputMessage extends ReactiveHttpOutputMessage { + + /** + * Use the given {@link File} to write the body of the message to the underlying + * HTTP layer. + * @param file the file to transfer + * @param position the position within the file from which the transfer is to begin + * @param count the number of bytes to be transferred + * @return a publisher that indicates completion or error. + */ + Mono writeWith(File file, long position, long count); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/http/client/reactive/AbstractClientHttpRequest.java b/spring-web-reactive/src/main/java/org/springframework/http/client/reactive/AbstractClientHttpRequest.java new file mode 100644 index 0000000000..e448cb149f --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/client/reactive/AbstractClientHttpRequest.java @@ -0,0 +1,107 @@ +/* + * Copyright 2002-2016 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.client.reactive; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; + +import reactor.core.publisher.Mono; + +import org.springframework.http.HttpCookie; +import org.springframework.http.HttpHeaders; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +/** + * Base class for {@link ClientHttpRequest} implementations. + * + * @author Rossen Stoyanchev + * @author Brian Clozel + */ +public abstract class AbstractClientHttpRequest implements ClientHttpRequest { + + private final HttpHeaders headers; + + private final MultiValueMap cookies; + + private AtomicReference state = new AtomicReference<>(State.NEW); + + private final List>> beforeCommitActions = new ArrayList<>(4); + + public AbstractClientHttpRequest() { + this(new HttpHeaders()); + } + + public AbstractClientHttpRequest(HttpHeaders headers) { + Assert.notNull(headers); + this.headers = headers; + this.cookies = new LinkedMultiValueMap<>(); + } + + @Override + public HttpHeaders getHeaders() { + if (State.COMITTED.equals(this.state.get())) { + return HttpHeaders.readOnlyHttpHeaders(this.headers); + } + return this.headers; + } + + @Override + public MultiValueMap getCookies() { + if (State.COMITTED.equals(this.state.get())) { + return CollectionUtils.unmodifiableMultiValueMap(this.cookies); + } + return this.cookies; + } + + protected Mono applyBeforeCommit() { + Mono mono = Mono.empty(); + if (this.state.compareAndSet(State.NEW, State.COMMITTING)) { + for (Supplier> action : this.beforeCommitActions) { + mono = mono.then(() -> action.get()); + } + return mono + .otherwise(ex -> { + // Ignore errors from beforeCommit actions + return Mono.empty(); + }) + .then(() -> { + this.state.set(State.COMITTED); + writeHeaders(); + writeCookies(); + return Mono.empty(); + }); + } + return mono; + } + + @Override + public void beforeCommit(Supplier> action) { + Assert.notNull(action); + this.beforeCommitActions.add(action); + } + + protected abstract void writeHeaders(); + + protected abstract void writeCookies(); + + private enum State {NEW, COMMITTING, COMITTED} +} \ No newline at end of file diff --git a/spring-web-reactive/src/main/java/org/springframework/http/client/reactive/ClientHttpConnector.java b/spring-web-reactive/src/main/java/org/springframework/http/client/reactive/ClientHttpConnector.java new file mode 100644 index 0000000000..bea20e29e0 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/client/reactive/ClientHttpConnector.java @@ -0,0 +1,52 @@ +/* + * Copyright 2002-2016 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.client.reactive; + +import java.net.URI; +import java.util.function.Function; + +import reactor.core.publisher.Mono; + +import org.springframework.http.HttpMethod; + +/** + * Client abstraction for HTTP client runtimes. + * {@link ClientHttpConnector} drives the underlying HTTP client implementation + * so as to connect to the origin server and provide all the necessary infrastructure + * to send the actual {@link ClientHttpRequest} and receive the {@link ClientHttpResponse} + * + * @author Brian Clozel + */ +public interface ClientHttpConnector { + + /** + * Connect to the origin server using the given {@code HttpMethod} and {@code URI}, + * then apply the given {@code requestCallback} on the {@link ClientHttpRequest} + * once the connection has been established. + *

Return a publisher of the {@link ClientHttpResponse}. + * + * @param method the HTTP request method + * @param uri the HTTP request URI + * @param requestCallback a function that prepares and writes the request, + * returning a publisher that signals when it's done interacting with the request. + * Implementations should return a {@code Mono} by calling + * {@link ClientHttpRequest#writeWith} or {@link ClientHttpRequest#setComplete}. + * @return a publisher of the {@link ClientHttpResponse} + */ + Mono connect(HttpMethod method, URI uri, + Function> requestCallback); + +} \ No newline at end of file diff --git a/spring-web-reactive/src/main/java/org/springframework/http/client/reactive/ClientHttpRequest.java b/spring-web-reactive/src/main/java/org/springframework/http/client/reactive/ClientHttpRequest.java new file mode 100644 index 0000000000..9819a372e3 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/client/reactive/ClientHttpRequest.java @@ -0,0 +1,49 @@ +/* + * Copyright 2002-2016 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.client.reactive; + +import java.net.URI; + +import org.springframework.http.HttpCookie; +import org.springframework.http.HttpMethod; +import org.springframework.http.ReactiveHttpOutputMessage; +import org.springframework.util.MultiValueMap; + +/** + * Represents a reactive client-side HTTP request. + * + * @author Arjen Poutsma + * @author Brian Clozel + */ +public interface ClientHttpRequest extends ReactiveHttpOutputMessage { + + /** + * Return the HTTP method of the request. + */ + HttpMethod getMethod(); + + /** + * Return the URI of the request. + */ + URI getURI(); + + /** + * Return a mutable map of request cookies to send to the server. + */ + MultiValueMap getCookies(); + +} \ No newline at end of file diff --git a/spring-web-reactive/src/main/java/org/springframework/http/client/reactive/ClientHttpResponse.java b/spring-web-reactive/src/main/java/org/springframework/http/client/reactive/ClientHttpResponse.java new file mode 100644 index 0000000000..502102b227 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/client/reactive/ClientHttpResponse.java @@ -0,0 +1,41 @@ +/* + * Copyright 2002-2016 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.client.reactive; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ReactiveHttpInputMessage; +import org.springframework.http.ResponseCookie; +import org.springframework.util.MultiValueMap; + +/** + * Represents a reactive client-side HTTP response. + * + * @author Arjen Poutsma + */ +public interface ClientHttpResponse extends ReactiveHttpInputMessage { + + /** + * @return the HTTP status as an {@link HttpStatus} enum value + */ + HttpStatus getStatusCode(); + + /** + * Return a read-only map of response cookies received from the server. + */ + MultiValueMap getCookies(); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/http/client/reactive/ReactorClientHttpConnector.java b/spring-web-reactive/src/main/java/org/springframework/http/client/reactive/ReactorClientHttpConnector.java new file mode 100644 index 0000000000..a5f38eb162 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/client/reactive/ReactorClientHttpConnector.java @@ -0,0 +1,43 @@ +/* + * Copyright 2002-2016 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.client.reactive; + +import java.net.URI; +import java.util.function.Function; + +import org.springframework.http.HttpMethod; + +import reactor.core.publisher.Mono; + +/** + * Reactor-Netty implementation of {@link ClientHttpConnector} + * + * @author Brian Clozel + */ +public class ReactorClientHttpConnector implements ClientHttpConnector { + + @Override + public Mono connect(HttpMethod method, URI uri, + Function> requestCallback) { + + return reactor.io.netty.http.HttpClient.create(uri.getHost(), uri.getPort()) + .request(io.netty.handler.codec.http.HttpMethod.valueOf(method.name()), + uri.toString(), + httpOutbound -> requestCallback.apply(new ReactorClientHttpRequest(method, uri, httpOutbound))) + .map(httpInbound -> new ReactorClientHttpResponse(httpInbound)); + } +} \ No newline at end of file diff --git a/spring-web-reactive/src/main/java/org/springframework/http/client/reactive/ReactorClientHttpRequest.java b/spring-web-reactive/src/main/java/org/springframework/http/client/reactive/ReactorClientHttpRequest.java new file mode 100644 index 0000000000..f9573f7ea4 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/client/reactive/ReactorClientHttpRequest.java @@ -0,0 +1,108 @@ +/* + * Copyright 2002-2016 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.client.reactive; + +import java.net.URI; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.handler.codec.http.cookie.DefaultCookie; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.io.netty.http.HttpClient; +import reactor.io.netty.http.HttpClientRequest; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.io.buffer.NettyDataBuffer; +import org.springframework.core.io.buffer.NettyDataBufferFactory; +import org.springframework.http.HttpMethod; + +/** + * {@link ClientHttpRequest} implementation for the Reactor-Netty HTTP client + * + * @author Brian Clozel + * @see reactor.io.netty.http.HttpClient + */ +public class ReactorClientHttpRequest extends AbstractClientHttpRequest { + + private final HttpMethod httpMethod; + + private final URI uri; + + private final HttpClientRequest httpRequest; + + private final NettyDataBufferFactory bufferFactory; + + public ReactorClientHttpRequest(HttpMethod httpMethod, URI uri, HttpClientRequest httpRequest) { + this.httpMethod = httpMethod; + this.uri = uri; + this.httpRequest = httpRequest; + this.bufferFactory = new NettyDataBufferFactory(httpRequest.delegate().alloc()); + } + + @Override + public DataBufferFactory bufferFactory() { + return this.bufferFactory; + } + + @Override + public HttpMethod getMethod() { + return this.httpMethod; + } + + @Override + public URI getURI() { + return this.uri; + } + + @Override + public Mono writeWith(Publisher body) { + return applyBeforeCommit() + .then(httpRequest.send(Flux.from(body).map(this::toByteBuf))); + } + + @Override + public Mono setComplete() { + return applyBeforeCommit().then(httpRequest.sendHeaders()); + } + + private ByteBuf toByteBuf(DataBuffer buffer) { + if (buffer instanceof NettyDataBuffer) { + return ((NettyDataBuffer) buffer).getNativeBuffer(); + } + else { + return Unpooled.wrappedBuffer(buffer.asByteBuffer()); + } + } + + @Override + protected void writeHeaders() { + getHeaders().entrySet().stream() + .forEach(e -> this.httpRequest.headers().set(e.getKey(), e.getValue())); + } + + @Override + protected void writeCookies() { + getCookies().values() + .stream().flatMap(cookies -> cookies.stream()) + .map(cookie -> new DefaultCookie(cookie.getName(), cookie.getValue())) + .forEach(cookie -> this.httpRequest.addCookie(cookie)); + } + +} \ No newline at end of file diff --git a/spring-web-reactive/src/main/java/org/springframework/http/client/reactive/ReactorClientHttpResponse.java b/spring-web-reactive/src/main/java/org/springframework/http/client/reactive/ReactorClientHttpResponse.java new file mode 100644 index 0000000000..049f20fd63 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/client/reactive/ReactorClientHttpResponse.java @@ -0,0 +1,98 @@ +/* + * Copyright 2002-2016 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.client.reactive; + +import java.util.Collection; +import java.util.function.Function; + +import io.netty.buffer.ByteBuf; +import reactor.core.publisher.Flux; +import reactor.io.netty.http.HttpClientResponse; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.io.buffer.NettyDataBufferFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; +import org.springframework.util.CollectionUtils; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +/** + * {@link ClientHttpResponse} implementation for the Reactor-Netty HTTP client + * + * @author Brian Clozel + * @see reactor.io.netty.http.HttpClient + */ +public class ReactorClientHttpResponse implements ClientHttpResponse { + + private final NettyDataBufferFactory dataBufferFactory; + + private final HttpClientResponse response; + + public ReactorClientHttpResponse(HttpClientResponse response) { + this.response = response; + this.dataBufferFactory = new NettyDataBufferFactory(response.delegate().alloc()); + } + + @Override + public Flux getBody() { + return response.receive() + .map(buf -> { + buf.retain(); + return dataBufferFactory.wrap(buf); + }); + } + + @Override + public HttpHeaders getHeaders() { + HttpHeaders headers = new HttpHeaders(); + this.response.responseHeaders().entries().stream().forEach(e -> headers.add(e.getKey(), e.getValue())); + return headers; + } + + @Override + public HttpStatus getStatusCode() { + return HttpStatus.valueOf(this.response.status().code()); + } + + @Override + public MultiValueMap getCookies() { + MultiValueMap result = new LinkedMultiValueMap<>(); + this.response.cookies().values().stream().flatMap(Collection::stream) + .forEach(cookie -> { + ResponseCookie responseCookie = ResponseCookie.from(cookie.name(), cookie.value()) + .domain(cookie.domain()) + .path(cookie.path()) + .maxAge(cookie.maxAge()) + .secure(cookie.isSecure()) + .httpOnly(cookie.isHttpOnly()) + .build(); + result.add(cookie.name(), responseCookie); + }); + return CollectionUtils.unmodifiableMultiValueMap(result); + } + + @Override + public String toString() { + return "ReactorClientHttpResponse{" + + "request=" + this.response.method().name() + " " + this.response.uri() + "," + + "status=" + getStatusCode() + + '}'; + } +} \ No newline at end of file diff --git a/spring-web-reactive/src/main/java/org/springframework/http/client/reactive/package-info.java b/spring-web-reactive/src/main/java/org/springframework/http/client/reactive/package-info.java new file mode 100644 index 0000000000..99f7e54bb6 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/client/reactive/package-info.java @@ -0,0 +1,7 @@ +/** + * Core package of the reactive client HTTP support. + * Provides {@link org.springframework.http.client.reactive.ClientHttpRequest} + * and {@link org.springframework.http.client.reactive.ClientHttpResponse} + * interfaces and their implementations + */ +package org.springframework.http.client.reactive; diff --git a/spring-web-reactive/src/main/java/org/springframework/http/codec/SseEventEncoder.java b/spring-web-reactive/src/main/java/org/springframework/http/codec/SseEventEncoder.java new file mode 100644 index 0000000000..f5dfa2f9b1 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/codec/SseEventEncoder.java @@ -0,0 +1,135 @@ +/* + * Copyright 2002-2016 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.codec; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Optional; + +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.CodecException; +import org.springframework.core.codec.Encoder; +import org.springframework.core.codec.AbstractEncoder; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.io.buffer.FlushingDataBuffer; +import org.springframework.http.MediaType; +import org.springframework.util.Assert; +import org.springframework.util.MimeType; +import org.springframework.web.reactive.sse.SseEvent; + +/** + * An encoder for {@link SseEvent}s that also supports any other kind of {@link Object} + * (in that case, the object will be the data of the {@link SseEvent}). + * @author Sebastien Deleuze + */ +public class SseEventEncoder extends AbstractEncoder { + + private final List> dataEncoders; + + + public SseEventEncoder(List> dataEncoders) { + super(new MimeType("text", "event-stream")); + Assert.notNull(dataEncoders, "'dataEncoders' must not be null"); + this.dataEncoders = dataEncoders; + } + + @Override + public Flux encode(Publisher inputStream, DataBufferFactory bufferFactory, + ResolvableType type, MimeType sseMimeType, Object... hints) { + + return Flux.from(inputStream).flatMap(input -> { + SseEvent event = (SseEvent.class.equals(type.getRawClass()) ? + (SseEvent)input : new SseEvent(input)); + + StringBuilder sb = new StringBuilder(); + + if (event.getId() != null) { + sb.append("id:"); + sb.append(event.getId()); + sb.append("\n"); + } + + if (event.getName() != null) { + sb.append("event:"); + sb.append(event.getName()); + sb.append("\n"); + } + + if (event.getReconnectTime() != null) { + sb.append("retry:"); + sb.append(event.getReconnectTime().toString()); + sb.append("\n"); + } + + if (event.getComment() != null) { + sb.append(":"); + sb.append(event.getComment().replaceAll("\\n", "\n:")); + sb.append("\n"); + } + + Object data = event.getData(); + Flux dataBuffer = Flux.empty(); + MediaType mediaType = (event.getMediaType() == null ? + MediaType.ALL : event.getMediaType()); + if (data != null) { + sb.append("data:"); + if (data instanceof String) { + sb.append(((String)data).replaceAll("\\n", "\ndata:")).append("\n"); + } + else { + Optional> encoder = dataEncoders + .stream() + .filter(e -> e.canEncode(ResolvableType.forClass(data.getClass()), mediaType)) + .findFirst(); + + if (encoder.isPresent()) { + dataBuffer = ((Encoder)encoder.get()) + .encode(Mono.just(data), bufferFactory, + ResolvableType.forClass(data.getClass()), mediaType) + .concatWith(encodeString("\n", bufferFactory)); + } + else { + throw new CodecException("No suitable encoder found!"); + } + } + } + + // Keep the SSE connection open even for cold stream in order to avoid + // unexpected browser reconnection + return Flux.concat( + encodeString(sb.toString(), bufferFactory), + dataBuffer, + encodeString("\n", bufferFactory), + Mono.just(FlushingDataBuffer.INSTANCE), + Flux.never() + ); + }); + + } + + private Mono encodeString(String str, DataBufferFactory bufferFactory) { + byte[] bytes = str.getBytes(StandardCharsets.UTF_8); + DataBuffer buffer = bufferFactory.allocateBuffer(bytes.length).write(bytes); + return Mono.just(buffer); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/http/codec/json/JacksonJsonDecoder.java b/spring-web-reactive/src/main/java/org/springframework/http/codec/json/JacksonJsonDecoder.java new file mode 100644 index 0000000000..037e46299e --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/codec/json/JacksonJsonDecoder.java @@ -0,0 +1,112 @@ +/* + * Copyright 2002-2016 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.codec.json; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectReader; +import com.fasterxml.jackson.databind.type.TypeFactory; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.CodecException; +import org.springframework.core.codec.AbstractDecoder; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.support.DataBufferUtils; +import org.springframework.util.Assert; +import org.springframework.util.MimeType; + + +/** + * Decode a byte stream into JSON and convert to Object's with Jackson. + * + * @author Sebastien Deleuze + * @author Rossen Stoyanchev + * + * @see JacksonJsonEncoder + */ +public class JacksonJsonDecoder extends AbstractDecoder { + + private static final MimeType[] MIME_TYPES = new MimeType[] { + new MimeType("application", "json", StandardCharsets.UTF_8), + new MimeType("application", "*+json", StandardCharsets.UTF_8) + }; + + + private final ObjectMapper mapper; + + private final JsonObjectDecoder fluxObjectDecoder = new JsonObjectDecoder(true); + + private final JsonObjectDecoder monoObjectDecoder = new JsonObjectDecoder(false); + + + public JacksonJsonDecoder() { + this(new ObjectMapper()); + } + + public JacksonJsonDecoder(ObjectMapper mapper) { + super(MIME_TYPES); + this.mapper = mapper; + } + + + @Override + public Flux decode(Publisher inputStream, ResolvableType elementType, + MimeType mimeType, Object... hints) { + + JsonObjectDecoder objectDecoder = this.fluxObjectDecoder; + return decodeInternal(objectDecoder, inputStream, elementType, mimeType, hints); + } + + @Override + public Mono decodeToMono(Publisher inputStream, ResolvableType elementType, + MimeType mimeType, Object... hints) { + + JsonObjectDecoder objectDecoder = this.monoObjectDecoder; + return decodeInternal(objectDecoder, inputStream, elementType, mimeType, hints).single(); + } + + private Flux decodeInternal(JsonObjectDecoder objectDecoder, Publisher inputStream, + ResolvableType elementType, MimeType mimeType, Object[] hints) { + + Assert.notNull(inputStream, "'inputStream' must not be null"); + Assert.notNull(elementType, "'elementType' must not be null"); + + TypeFactory typeFactory = this.mapper.getTypeFactory(); + JavaType javaType = typeFactory.constructType(elementType.getType()); + + ObjectReader reader = this.mapper.readerFor(javaType); + + return objectDecoder.decode(inputStream, elementType, mimeType, hints) + .map(dataBuffer -> { + try { + Object value = reader.readValue(dataBuffer.asInputStream()); + DataBufferUtils.release(dataBuffer); + return value; + } + catch (IOException e) { + return Flux.error(new CodecException("Error while reading the data", e)); + } + }); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/http/codec/json/JacksonJsonEncoder.java b/spring-web-reactive/src/main/java/org/springframework/http/codec/json/JacksonJsonEncoder.java new file mode 100644 index 0000000000..53792aaf4a --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/codec/json/JacksonJsonEncoder.java @@ -0,0 +1,114 @@ +/* + * Copyright 2002-2016 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.codec.json; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; + +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectWriter; +import com.fasterxml.jackson.databind.type.TypeFactory; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.CodecException; +import org.springframework.core.codec.AbstractEncoder; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.util.Assert; +import org.springframework.util.MimeType; + +/** + * Encode from an {@code Object} stream to a byte stream of JSON objects. + * + * @author Sebastien Deleuze + * @author Arjen Poutsma + * @see JacksonJsonDecoder + */ +public class JacksonJsonEncoder extends AbstractEncoder { + + private static final ByteBuffer START_ARRAY_BUFFER = ByteBuffer.wrap(new byte[]{'['}); + + private static final ByteBuffer SEPARATOR_BUFFER = ByteBuffer.wrap(new byte[]{','}); + + private static final ByteBuffer END_ARRAY_BUFFER = ByteBuffer.wrap(new byte[]{']'}); + + + private final ObjectMapper mapper; + + + public JacksonJsonEncoder() { + this(new ObjectMapper()); + } + + public JacksonJsonEncoder(ObjectMapper mapper) { + super(new MimeType("application", "json", StandardCharsets.UTF_8), + new MimeType("application", "*+json", StandardCharsets.UTF_8)); + Assert.notNull(mapper, "'mapper' must not be null"); + + this.mapper = mapper; + } + + + @Override + public Flux encode(Publisher inputStream, DataBufferFactory bufferFactory, + ResolvableType elementType, MimeType mimeType, Object... hints) { + + Assert.notNull(inputStream, "'inputStream' must not be null"); + Assert.notNull(bufferFactory, "'bufferFactory' must not be null"); + Assert.notNull(elementType, "'elementType' must not be null"); + + if (inputStream instanceof Mono) { + return Flux.from(inputStream).map(value -> encodeValue(value, bufferFactory, elementType)); + } + + Mono startArray = Mono.just(bufferFactory.wrap(START_ARRAY_BUFFER)); + Mono endArray = Mono.just(bufferFactory.wrap(END_ARRAY_BUFFER)); + + Flux array = Flux.from(inputStream) + .flatMap(value -> { + DataBuffer arraySeparator = bufferFactory.wrap(SEPARATOR_BUFFER); + return Flux.just(encodeValue(value, bufferFactory, elementType), arraySeparator); + }); + + return Flux.concat(startArray, array.skipLast(1), endArray); + } + + private DataBuffer encodeValue(Object value, DataBufferFactory bufferFactory, ResolvableType type) { + TypeFactory typeFactory = this.mapper.getTypeFactory(); + JavaType javaType = typeFactory.constructType(type.getType()); + ObjectWriter writer = this.mapper.writerFor(javaType); + + DataBuffer buffer = bufferFactory.allocateBuffer(); + OutputStream outputStream = buffer.asOutputStream(); + + try { + writer.writeValue(outputStream, value); + } + catch (IOException e) { + throw new CodecException("Error while writing the data", e); + } + + return buffer; + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/http/codec/json/JsonObjectDecoder.java b/spring-web-reactive/src/main/java/org/springframework/http/codec/json/JsonObjectDecoder.java new file mode 100644 index 0000000000..1f8caf1cd9 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/codec/json/JsonObjectDecoder.java @@ -0,0 +1,265 @@ +/* + * Copyright 2002-2016 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.codec.json; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufUtil; +import io.netty.buffer.Unpooled; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; + +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.AbstractDecoder; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.io.buffer.support.DataBufferUtils; +import org.springframework.util.MimeType; + +/** + * Decode an arbitrary split byte stream representing JSON objects to a byte + * stream where each chunk is a well-formed JSON object. + * + * This class does not do any real parsing or validation. A sequence of byte + * is considered a JSON object/array if it contains a matching number of opening + * and closing braces/brackets. + * + * Based on Netty JsonObjectDecoder + * + * @author Sebastien Deleuze + */ +class JsonObjectDecoder extends AbstractDecoder { + + private static final int ST_CORRUPTED = -1; + + private static final int ST_INIT = 0; + + private static final int ST_DECODING_NORMAL = 1; + + private static final int ST_DECODING_ARRAY_STREAM = 2; + + private final int maxObjectLength; + + private final boolean streamArrayElements; + + public JsonObjectDecoder() { + // 1 MB + this(1024 * 1024); + } + + public JsonObjectDecoder(int maxObjectLength) { + this(maxObjectLength, true); + } + + public JsonObjectDecoder(boolean streamArrayElements) { + this(1024 * 1024, streamArrayElements); + } + + + /** + * @param maxObjectLength maximum number of bytes a JSON object/array may + * use (including braces and all). Objects exceeding this length are dropped + * and an {@link IllegalStateException} is thrown. + * @param streamArrayElements if set to true and the "top level" JSON object + * is an array, each of its entries is passed through the pipeline individually + * and immediately after it was fully received, allowing for arrays with + */ + public JsonObjectDecoder(int maxObjectLength, + boolean streamArrayElements) { + super(new MimeType("application", "json", StandardCharsets.UTF_8), + new MimeType("application", "*+json", StandardCharsets.UTF_8)); + if (maxObjectLength < 1) { + throw new IllegalArgumentException("maxObjectLength must be a positive int"); + } + this.maxObjectLength = maxObjectLength; + this.streamArrayElements = streamArrayElements; + } + + @Override + public Flux decode(Publisher inputStream, ResolvableType elementType, + MimeType mimeType, Object... hints) { + + return Flux.from(inputStream) + .flatMap(new Function>() { + + int openBraces; + int index; + int state; + boolean insideString; + ByteBuf input; + Integer writerIndex; + + @Override + public Publisher apply(DataBuffer b) { + List chunks = new ArrayList<>(); + if (this.input == null) { + this.input = Unpooled.copiedBuffer(b.asByteBuffer()); + DataBufferUtils.release(b); + this.writerIndex = this.input.writerIndex(); + } + else { + this.input = Unpooled.copiedBuffer(this.input, + Unpooled.copiedBuffer(b.asByteBuffer())); + DataBufferUtils.release(b); + this.writerIndex = this.input.writerIndex(); + } + if (this.state == ST_CORRUPTED) { + this.input.skipBytes(this.input.readableBytes()); + return Flux.error(new IllegalStateException("Corrupted stream")); + } + if (this.writerIndex > maxObjectLength) { + // buffer size exceeded maxObjectLength; discarding the complete buffer. + this.input.skipBytes(this.input.readableBytes()); + reset(); + return Flux.error(new IllegalStateException("object length exceeds " + + maxObjectLength + ": " + this.writerIndex + " bytes discarded")); + } + DataBufferFactory dataBufferFactory = b.factory(); + for (/* use current index */; this.index < this.writerIndex; this.index++) { + byte c = this.input.getByte(this.index); + if (this.state == ST_DECODING_NORMAL) { + decodeByte(c, this.input, this.index); + + // All opening braces/brackets have been closed. That's enough to conclude + // that the JSON object/array is complete. + if (this.openBraces == 0) { + ByteBuf json = extractObject(this.input, this.input.readerIndex(), + this.index + 1 - this.input.readerIndex()); + if (json != null) { + chunks.add(dataBufferFactory.wrap(json.nioBuffer())); + } + + // The JSON object/array was extracted => discard the bytes from + // the input buffer. + this.input.readerIndex(this.index + 1); + // Reset the object state to get ready for the next JSON object/text + // coming along the byte stream. + reset(); + } + } + else if (this.state == ST_DECODING_ARRAY_STREAM) { + decodeByte(c, this.input, this.index); + + if (!this.insideString && (this.openBraces == 1 && c == ',' || + this.openBraces == 0 && c == ']')) { + // skip leading spaces. No range check is needed and the loop will terminate + // because the byte at position index is not a whitespace. + for (int i = this.input.readerIndex(); Character.isWhitespace(this.input.getByte(i)); i++) { + this.input.skipBytes(1); + } + + // skip trailing spaces. + int idxNoSpaces = this.index - 1; + while (idxNoSpaces >= this.input.readerIndex() && + Character.isWhitespace(this.input.getByte(idxNoSpaces))) { + + idxNoSpaces--; + } + + ByteBuf json = extractObject(this.input, this.input.readerIndex(), + idxNoSpaces + 1 - this.input.readerIndex()); + + if (json != null) { + chunks.add(dataBufferFactory.wrap(json.nioBuffer())); + } + + this.input.readerIndex(this.index + 1); + + if (c == ']') { + reset(); + } + } + // JSON object/array detected. Accumulate bytes until all braces/brackets are closed. + } + else if (c == '{' || c == '[') { + initDecoding(c, streamArrayElements); + + if (this.state == ST_DECODING_ARRAY_STREAM) { + // Discard the array bracket + this.input.skipBytes(1); + } + // Discard leading spaces in front of a JSON object/array. + } + else if (Character.isWhitespace(c)) { + this.input.skipBytes(1); + } + else { + this.state = ST_CORRUPTED; + return Flux.error(new IllegalStateException( + "invalid JSON received at byte position " + this.index + ": " + + ByteBufUtil.hexDump(this.input))); + } + } + + if (this.input.readableBytes() == 0) { + this.index = 0; + } + return Flux.fromIterable(chunks); + } + + /** + * Override this method if you want to filter the json objects/arrays that + * get passed through the pipeline. + */ + @SuppressWarnings("UnusedParameters") + protected ByteBuf extractObject(ByteBuf buffer, int index, int length) { + return buffer.slice(index, length).retain(); + } + + private void decodeByte(byte c, ByteBuf input, int index) { + if ((c == '{' || c == '[') && !this.insideString) { + this.openBraces++; + } + else if ((c == '}' || c == ']') && !this.insideString) { + this.openBraces--; + } + else if (c == '"') { + // start of a new JSON string. It's necessary to detect strings as they may + // also contain braces/brackets and that could lead to incorrect results. + if (!this.insideString) { + this.insideString = true; + // If the double quote wasn't escaped then this is the end of a string. + } + else if (input.getByte(index - 1) != '\\') { + this.insideString = false; + } + } + } + + private void initDecoding(byte openingBrace, boolean streamArrayElements) { + this.openBraces = 1; + if (openingBrace == '[' && streamArrayElements) { + this.state = ST_DECODING_ARRAY_STREAM; + } + else { + this.state = ST_DECODING_NORMAL; + } + } + + private void reset() { + this.insideString = false; + this.state = ST_INIT; + this.openBraces = 0; + } + }); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/http/codec/xml/Jaxb2Decoder.java b/spring-web-reactive/src/main/java/org/springframework/http/codec/xml/Jaxb2Decoder.java new file mode 100644 index 0000000000..d0dfe8ae6d --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/codec/xml/Jaxb2Decoder.java @@ -0,0 +1,216 @@ +/* + * Copyright 2002-2016 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.codec.xml; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; +import javax.xml.XMLConstants; +import javax.xml.bind.JAXBElement; +import javax.xml.bind.JAXBException; +import javax.xml.bind.Unmarshaller; +import javax.xml.bind.annotation.XmlRootElement; +import javax.xml.bind.annotation.XmlSchema; +import javax.xml.bind.annotation.XmlType; +import javax.xml.namespace.QName; +import javax.xml.stream.XMLEventReader; +import javax.xml.stream.events.XMLEvent; + +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.CodecException; +import org.springframework.core.codec.AbstractDecoder; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.util.ClassUtils; +import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils; +import org.springframework.util.xml.StaxUtils2; + +/** + * Decode from a bytes stream of XML elements to a stream of {@code Object} (POJO). + * + * @author Sebastien Deleuze + * @author Arjen Poutsma + * @see Jaxb2Encoder + */ +public class Jaxb2Decoder extends AbstractDecoder { + + /** + * The default value for JAXB annotations. + * @see XmlRootElement#name() + * @see XmlRootElement#namespace() + * @see XmlType#name() + * @see XmlType#namespace() + */ + private final static String JAXB_DEFAULT_ANNOTATION_VALUE = "##default"; + + private final XmlEventDecoder xmlEventDecoder = new XmlEventDecoder(); + + private final JaxbContextContainer jaxbContexts = new JaxbContextContainer(); + + public Jaxb2Decoder() { + super(MimeTypeUtils.APPLICATION_XML, MimeTypeUtils.TEXT_XML); + } + + @Override + public boolean canDecode(ResolvableType elementType, MimeType mimeType, Object... hints) { + if (super.canDecode(elementType, mimeType, hints)) { + Class outputClass = elementType.getRawClass(); + return outputClass.isAnnotationPresent(XmlRootElement.class) || + outputClass.isAnnotationPresent(XmlType.class); + } + else { + return false; + } + } + + @Override + public Flux decode(Publisher inputStream, ResolvableType elementType, + MimeType mimeType, Object... hints) { + Class outputClass = elementType.getRawClass(); + Flux xmlEventFlux = + this.xmlEventDecoder.decode(inputStream, null, mimeType); + + QName typeName = toQName(outputClass); + Flux> splitEvents = split(xmlEventFlux, typeName); + + return splitEvents.map(events -> unmarshal(events, outputClass)); + } + + /** + * Returns the qualified name for the given class, according to the mapping rules + * in the JAXB specification. + */ + QName toQName(Class outputClass) { + String localPart; + String namespaceUri; + + if (outputClass.isAnnotationPresent(XmlRootElement.class)) { + XmlRootElement annotation = outputClass.getAnnotation(XmlRootElement.class); + localPart = annotation.name(); + namespaceUri = annotation.namespace(); + } + else if (outputClass.isAnnotationPresent(XmlType.class)) { + XmlType annotation = outputClass.getAnnotation(XmlType.class); + localPart = annotation.name(); + namespaceUri = annotation.namespace(); + } + else { + throw new IllegalArgumentException("Outputclass [" + outputClass + "] is " + + "neither annotated with @XmlRootElement nor @XmlType"); + } + + if (JAXB_DEFAULT_ANNOTATION_VALUE.equals(localPart)) { + localPart = ClassUtils.getShortNameAsProperty(outputClass); + } + if (JAXB_DEFAULT_ANNOTATION_VALUE.equals(namespaceUri)) { + Package outputClassPackage = outputClass.getPackage(); + if (outputClassPackage != null && + outputClassPackage.isAnnotationPresent(XmlSchema.class)) { + XmlSchema annotation = outputClassPackage.getAnnotation(XmlSchema.class); + namespaceUri = annotation.namespace(); + } + else { + namespaceUri = XMLConstants.NULL_NS_URI; + } + } + return new QName(namespaceUri, localPart); + } + + /** + * Split a flux of {@link XMLEvent}s into a flux of XMLEvent lists, one list for each + * branch of the tree that starts with the given qualified name. + * That is, given the XMLEvents shown + * {@linkplain XmlEventDecoder here}, + * and the {@code desiredName} "{@code child}", this method + * returns a flux of two lists, each of which containing the events of a particular + * branch of the tree that starts with "{@code child}". + *
    + *
  1. The first list, dealing with the first branch of the tree + *
      + *
    1. {@link javax.xml.stream.events.StartElement} {@code child}
    2. + *
    3. {@link javax.xml.stream.events.Characters} {@code foo}
    4. + *
    5. {@link javax.xml.stream.events.EndElement} {@code child}
    6. + *
    + *
  2. The second list, dealing with the second branch of the tree + *
      + *
    1. {@link javax.xml.stream.events.StartElement} {@code child}
    2. + *
    3. {@link javax.xml.stream.events.Characters} {@code bar}
    4. + *
    5. {@link javax.xml.stream.events.EndElement} {@code child}
    6. + *
    + *
  3. + *
+ */ + Flux> split(Flux xmlEventFlux, QName desiredName) { + return xmlEventFlux + .flatMap(new Function>>() { + + private List events = null; + + private int elementDepth = 0; + + private int barrier = Integer.MAX_VALUE; + + @Override + public Publisher> apply(XMLEvent event) { + if (event.isStartElement()) { + if (this.barrier == Integer.MAX_VALUE) { + QName startElementName = event.asStartElement().getName(); + if (desiredName.equals(startElementName)) { + this.events = new ArrayList(); + this.barrier = this.elementDepth; + } + } + this.elementDepth++; + } + if (this.elementDepth > this.barrier) { + this.events.add(event); + } + if (event.isEndElement()) { + this.elementDepth--; + if (this.elementDepth == this.barrier) { + this.barrier = Integer.MAX_VALUE; + return Mono.just(this.events); + } + } + return Mono.empty(); + } + }); + } + + private Object unmarshal(List events, Class outputClass) { + try { + Unmarshaller unmarshaller = this.jaxbContexts.createUnmarshaller(outputClass); + XMLEventReader eventReader = StaxUtils2.createXMLEventReader(events); + if (outputClass.isAnnotationPresent(XmlRootElement.class)) { + return unmarshaller.unmarshal(eventReader); + } + else { + JAXBElement jaxbElement = + unmarshaller.unmarshal(eventReader, outputClass); + return jaxbElement.getValue(); + } + } + catch (JAXBException ex) { + throw new CodecException(ex.getMessage(), ex); + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/http/codec/xml/Jaxb2Encoder.java b/spring-web-reactive/src/main/java/org/springframework/http/codec/xml/Jaxb2Encoder.java new file mode 100644 index 0000000000..b80bb2a324 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/codec/xml/Jaxb2Encoder.java @@ -0,0 +1,85 @@ +/* + * Copyright 2002-2016 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.codec.xml; + +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import javax.xml.bind.JAXBException; +import javax.xml.bind.Marshaller; +import javax.xml.bind.annotation.XmlRootElement; +import javax.xml.bind.annotation.XmlType; + +import reactor.core.publisher.Flux; + +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.AbstractSingleValueEncoder; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.util.ClassUtils; +import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils; + +/** + * Encode from an {@code Object} stream to a byte stream of XML elements. + * + * @author Sebastien Deleuze + * @author Arjen Poutsma + * @see Jaxb2Decoder + */ +public class Jaxb2Encoder extends AbstractSingleValueEncoder { + + private final JaxbContextContainer jaxbContexts = new JaxbContextContainer(); + + public Jaxb2Encoder() { + super(MimeTypeUtils.APPLICATION_XML, MimeTypeUtils.TEXT_XML); + } + + @Override + public boolean canEncode(ResolvableType elementType, MimeType mimeType, Object... hints) { + if (super.canEncode(elementType, mimeType, hints)) { + Class outputClass = elementType.getRawClass(); + return outputClass.isAnnotationPresent(XmlRootElement.class) || + outputClass.isAnnotationPresent(XmlType.class); + } + else { + return false; + } + + } + + @Override + protected Flux encode(Object value, DataBufferFactory dataBufferFactory, + ResolvableType type, MimeType mimeType, Object... hints) { + try { + DataBuffer buffer = dataBufferFactory.allocateBuffer(1024); + OutputStream outputStream = buffer.asOutputStream(); + Class clazz = ClassUtils.getUserClass(value); + Marshaller marshaller = jaxbContexts.createMarshaller(clazz); + marshaller + .setProperty(Marshaller.JAXB_ENCODING, StandardCharsets.UTF_8.name()); + marshaller.marshal(value, outputStream); + return Flux.just(buffer); + } + catch (JAXBException ex) { + return Flux.error(ex); + } + } + + + +} + diff --git a/spring-web-reactive/src/main/java/org/springframework/http/codec/xml/JaxbContextContainer.java b/spring-web-reactive/src/main/java/org/springframework/http/codec/xml/JaxbContextContainer.java new file mode 100644 index 0000000000..87ec8a4380 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/codec/xml/JaxbContextContainer.java @@ -0,0 +1,56 @@ +/* + * Copyright 2002-2016 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.codec.xml; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import javax.xml.bind.JAXBContext; +import javax.xml.bind.JAXBException; +import javax.xml.bind.Marshaller; +import javax.xml.bind.Unmarshaller; + +import org.springframework.util.Assert; + +/** + * @author Arjen Poutsma + */ +final class JaxbContextContainer { + + private final ConcurrentMap, JAXBContext> jaxbContexts = + new ConcurrentHashMap<>(64); + + public Marshaller createMarshaller(Class clazz) throws JAXBException { + JAXBContext jaxbContext = getJaxbContext(clazz); + return jaxbContext.createMarshaller(); + } + + public Unmarshaller createUnmarshaller(Class clazz) throws JAXBException { + JAXBContext jaxbContext = getJaxbContext(clazz); + return jaxbContext.createUnmarshaller(); + } + + private JAXBContext getJaxbContext(Class clazz) throws JAXBException { + Assert.notNull(clazz, "'clazz' must not be null"); + JAXBContext jaxbContext = this.jaxbContexts.get(clazz); + if (jaxbContext == null) { + jaxbContext = JAXBContext.newInstance(clazz); + this.jaxbContexts.putIfAbsent(clazz, jaxbContext); + } + return jaxbContext; + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/http/codec/xml/XmlEventDecoder.java b/spring-web-reactive/src/main/java/org/springframework/http/codec/xml/XmlEventDecoder.java new file mode 100644 index 0000000000..7009a00b12 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/codec/xml/XmlEventDecoder.java @@ -0,0 +1,157 @@ +/* + * Copyright 2002-2016 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.codec.xml; + +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; +import javax.xml.stream.XMLEventReader; +import javax.xml.stream.XMLInputFactory; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.events.XMLEvent; +import javax.xml.stream.util.XMLEventAllocator; + +import com.fasterxml.aalto.AsyncByteBufferFeeder; +import com.fasterxml.aalto.AsyncXMLInputFactory; +import com.fasterxml.aalto.AsyncXMLStreamReader; +import com.fasterxml.aalto.evt.EventAllocatorImpl; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.AbstractDecoder; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.support.DataBufferUtils; +import org.springframework.util.ClassUtils; +import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils; + +/** + * Decodes a {@link DataBuffer} stream into a stream of {@link XMLEvent}s. That is, given + * the following XML: + *
{@code
+ * 
+ *     foo
+ *     bar
+ * }
+ * 
+ * this method with result in a flux with the following events: + *
    + *
  1. {@link javax.xml.stream.events.StartDocument}
  2. + *
  3. {@link javax.xml.stream.events.StartElement} {@code root}
  4. + *
  5. {@link javax.xml.stream.events.StartElement} {@code child}
  6. + *
  7. {@link javax.xml.stream.events.Characters} {@code foo}
  8. + *
  9. {@link javax.xml.stream.events.EndElement} {@code child}
  10. + *
  11. {@link javax.xml.stream.events.StartElement} {@code child}
  12. + *
  13. {@link javax.xml.stream.events.Characters} {@code bar}
  14. + *
  15. {@link javax.xml.stream.events.EndElement} {@code child}
  16. + *
  17. {@link javax.xml.stream.events.EndElement} {@code root}
  18. + *
+ * + * Note that this decoder is not registered by default, but used internally by other + * decoders who are. + * + * @author Arjen Poutsma + */ +public class XmlEventDecoder extends AbstractDecoder { + + private static final boolean aaltoPresent = ClassUtils + .isPresent("com.fasterxml.aalto.AsyncXMLStreamReader", + XmlEventDecoder.class.getClassLoader()); + + private static final XMLInputFactory inputFactory = XMLInputFactory.newFactory(); + + boolean useAalto = true; + + public XmlEventDecoder() { + super(MimeTypeUtils.APPLICATION_XML, MimeTypeUtils.TEXT_XML); + } + + @Override + public Flux decode(Publisher inputStream, ResolvableType elementType, + MimeType mimeType, Object... hints) { + Flux flux = Flux.from(inputStream); + if (useAalto && aaltoPresent) { + return flux.flatMap(new AaltoDataBufferToXmlEvent()); + } + else { + Mono singleBuffer = flux.reduce(DataBuffer::write); + return singleBuffer. + flatMap(dataBuffer -> { + try { + InputStream is = dataBuffer.asInputStream(); + XMLEventReader eventReader = + inputFactory.createXMLEventReader(is); + return Flux + .fromIterable((Iterable) () -> eventReader); + } + catch (XMLStreamException ex) { + return Mono.error(ex); + } + finally { + DataBufferUtils.release(dataBuffer); + } + }); + } + } + + /* + * Separate static class to isolate Aalto dependency. + */ + private static class AaltoDataBufferToXmlEvent + implements Function> { + + private static final AsyncXMLInputFactory inputFactory = + (AsyncXMLInputFactory) XmlEventDecoder.inputFactory; + + private final AsyncXMLStreamReader streamReader = + inputFactory.createAsyncForByteBuffer(); + + private final XMLEventAllocator eventAllocator = + EventAllocatorImpl.getDefaultInstance(); + + @Override + public Publisher apply(DataBuffer dataBuffer) { + try { + streamReader.getInputFeeder().feedInput(dataBuffer.asByteBuffer()); + List events = new ArrayList<>(); + while (true) { + if (streamReader.next() == AsyncXMLStreamReader.EVENT_INCOMPLETE) { + // no more events with what currently has been fed to the reader + break; + } + else { + XMLEvent event = eventAllocator.allocate(streamReader); + events.add(event); + if (event.isEndDocument()) { + break; + } + } + } + return Flux.fromIterable(events); + } + catch (XMLStreamException ex) { + return Mono.error(ex); + } + finally { + DataBufferUtils.release(dataBuffer); + } + } + } +} diff --git a/spring-web-reactive/src/main/java/org/springframework/http/converter/reactive/CodecHttpMessageConverter.java b/spring-web-reactive/src/main/java/org/springframework/http/converter/reactive/CodecHttpMessageConverter.java new file mode 100644 index 0000000000..b88156eaec --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/converter/reactive/CodecHttpMessageConverter.java @@ -0,0 +1,189 @@ +/* + * Copyright 2002-2016 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.converter.reactive; + +import java.util.Collections; +import java.util.List; + +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.Decoder; +import org.springframework.core.codec.Encoder; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ReactiveHttpInputMessage; +import org.springframework.http.ReactiveHttpOutputMessage; +import org.springframework.http.support.MediaTypeUtils; + +/** + * Implementation of the {@link HttpMessageConverter} interface that delegates to + * {@link Encoder} and {@link Decoder}. + * + * @author Arjen Poutsma + * @author Sebastien Deleuze + * @author Rossen Stoyanchev + */ +public class CodecHttpMessageConverter implements HttpMessageConverter { + + private final Encoder encoder; + + private final Decoder decoder; + + private final List readableMediaTypes; + + private final List writableMediaTypes; + + + /** + * Create a {@code CodecHttpMessageConverter} with the given {@link Encoder}. When + * using this constructor, all read-related methods will in {@code false} or an + * {@link IllegalStateException}. + * @param encoder the encoder to use + */ + public CodecHttpMessageConverter(Encoder encoder) { + this(encoder, null); + } + + /** + * Create a {@code CodecHttpMessageConverter} with the given {@link Decoder}. When + * using this constructor, all write-related methods will in {@code false} or an + * {@link IllegalStateException}. + * @param decoder the decoder to use + */ + public CodecHttpMessageConverter(Decoder decoder) { + this(null, decoder); + } + + /** + * Create a {@code CodecHttpMessageConverter} with the given {@link Encoder} and + * {@link Decoder}. + * @param encoder the encoder to use, can be {@code null} + * @param decoder the decoder to use, can be {@code null} + */ + public CodecHttpMessageConverter(Encoder encoder, Decoder decoder) { + this.encoder = encoder; + this.decoder = decoder; + + this.readableMediaTypes = decoder != null ? + MediaTypeUtils.toMediaTypes(decoder.getDecodableMimeTypes()) : + Collections.emptyList(); + this.writableMediaTypes = encoder != null ? + MediaTypeUtils.toMediaTypes(encoder.getEncodableMimeTypes()) : + Collections.emptyList(); + } + + + @Override + public boolean canRead(ResolvableType type, MediaType mediaType) { + return this.decoder != null && this.decoder.canDecode(type, mediaType); + } + + @Override + public boolean canWrite(ResolvableType type, MediaType mediaType) { + return this.encoder != null && this.encoder.canEncode(type, mediaType); + } + + @Override + public List getReadableMediaTypes() { + return this.readableMediaTypes; + } + + @Override + public List getWritableMediaTypes() { + return this.writableMediaTypes; + } + + + @Override + public Flux read(ResolvableType type, ReactiveHttpInputMessage inputMessage) { + if (this.decoder == null) { + return Flux.error(new IllegalStateException("No decoder set")); + } + MediaType contentType = getContentType(inputMessage); + return this.decoder.decode(inputMessage.getBody(), type, contentType); + } + + @Override + public Mono readMono(ResolvableType type, ReactiveHttpInputMessage inputMessage) { + if (this.decoder == null) { + return Mono.error(new IllegalStateException("No decoder set")); + } + MediaType contentType = getContentType(inputMessage); + return this.decoder.decodeToMono(inputMessage.getBody(), type, contentType); + } + + private MediaType getContentType(ReactiveHttpInputMessage inputMessage) { + MediaType contentType = inputMessage.getHeaders().getContentType(); + return (contentType != null ? contentType : MediaType.APPLICATION_OCTET_STREAM); + } + + + @Override + public Mono write(Publisher inputStream, ResolvableType type, + MediaType contentType, ReactiveHttpOutputMessage outputMessage) { + + if (this.encoder == null) { + return Mono.error(new IllegalStateException("No decoder set")); + } + + HttpHeaders headers = outputMessage.getHeaders(); + if (headers.getContentType() == null) { + MediaType contentTypeToUse = contentType; + if (contentType == null || contentType.isWildcardType() || contentType.isWildcardSubtype()) { + contentTypeToUse = getDefaultContentType(type); + } + else if (MediaType.APPLICATION_OCTET_STREAM.equals(contentType)) { + MediaType mediaType = getDefaultContentType(type); + contentTypeToUse = (mediaType != null ? mediaType : contentTypeToUse); + } + if (contentTypeToUse != null) { + if (contentTypeToUse.getCharset() == null) { + MediaType mediaType = getDefaultContentType(type); + if (mediaType != null && mediaType.getCharset() != null) { + contentTypeToUse = new MediaType(contentTypeToUse, mediaType.getCharset()); + } + } + headers.setContentType(contentTypeToUse); + } + } + + DataBufferFactory bufferFactory = outputMessage.bufferFactory(); + Flux body = this.encoder.encode(inputStream, bufferFactory, type, contentType); + return outputMessage.writeWith(body); + } + + /** + * Return the default content type for the given {@code ResolvableType}. + * Used when {@link #write} is called without a concrete content type. + * + *

By default returns the first of {@link Encoder#getEncodableMimeTypes() + * encodableMimeTypes}, if any. + * + * @param elementType the type of element for encoding + * @return the content type, or {@code null} + */ + @SuppressWarnings("UnusedParameters") + protected MediaType getDefaultContentType(ResolvableType elementType) { + return (!this.writableMediaTypes.isEmpty() ? this.writableMediaTypes.get(0) : null); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/http/converter/reactive/HttpMessageConverter.java b/spring-web-reactive/src/main/java/org/springframework/http/converter/reactive/HttpMessageConverter.java new file mode 100644 index 0000000000..d79009faef --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/converter/reactive/HttpMessageConverter.java @@ -0,0 +1,102 @@ +/* + * Copyright 2002-2016 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.converter.reactive; + +import java.util.List; + +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.ResolvableType; +import org.springframework.http.MediaType; +import org.springframework.http.ReactiveHttpInputMessage; +import org.springframework.http.ReactiveHttpOutputMessage; + +/** + * Strategy interface that specifies a converter that can convert from and to HTTP + * requests and responses. + * @author Arjen Poutsma + */ +public interface HttpMessageConverter { + + /** + * Indicates whether the given class can be read by this converter. + * @param type the type to test for readability + * @param mediaType the media type to read, can be {@code null} if not specified. + * Typically the value of a {@code Content-Type} header. + * @return {@code true} if readable; {@code false} otherwise + */ + boolean canRead(ResolvableType type, MediaType mediaType); + + /** + * Return the list of {@link MediaType} objects that can be read by this converter. + * @return the list of supported readable media types + */ + List getReadableMediaTypes(); + + /** + * Read a {@link Flux} of the given type form the given input message, and returns it. + * @param type the type of object to return. This type must have previously been + * passed to the + * {@link #canRead canRead} method of this interface, which must have returned {@code + * true}. + * @param inputMessage the HTTP input message to read from + * @return the converted {@link Flux} of elements + */ + Flux read(ResolvableType type, ReactiveHttpInputMessage inputMessage); + + /** + * Read a {@link Mono} of the given type form the given input message, and returns it. + * @param type the type of object to return. This type must have previously been + * passed to the + * {@link #canRead canRead} method of this interface, which must have returned {@code + * true}. + * @param inputMessage the HTTP input message to read from + * @return the converted {@link Mono} of object + */ + Mono readMono(ResolvableType type, ReactiveHttpInputMessage inputMessage); + + /** + * Indicates whether the given class can be written by this converter. + * @param type the class to test for writability + * @param mediaType the media type to write, can be {@code null} if not specified. + * Typically the value of an {@code Accept} header. + * @return {@code true} if writable; {@code false} otherwise + */ + boolean canWrite(ResolvableType type, MediaType mediaType); + + /** + * Return the list of {@link MediaType} objects that can be written by this + * converter. + * @return the list of supported readable media types + */ + List getWritableMediaTypes(); + + /** + * Write an given object to the given output message. + * @param inputStream the input stream to write + * @param type the stream element type to process. + * @param contentType the content type to use when writing. May be {@code null} to + * indicate that the default content type of the converter must be used. + * @param outputMessage the message to write to + * @return + */ + Mono write(Publisher inputStream, + ResolvableType type, MediaType contentType, + ReactiveHttpOutputMessage outputMessage); +} \ No newline at end of file diff --git a/spring-web-reactive/src/main/java/org/springframework/http/converter/reactive/ResourceHttpMessageConverter.java b/spring-web-reactive/src/main/java/org/springframework/http/converter/reactive/ResourceHttpMessageConverter.java new file mode 100644 index 0000000000..d98e7c1a35 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/converter/reactive/ResourceHttpMessageConverter.java @@ -0,0 +1,131 @@ +/* + * Copyright 2002-2016 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.converter.reactive; + +import java.io.File; +import java.io.IOException; +import java.util.Optional; + +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.ResourceDecoder; +import org.springframework.core.codec.ResourceEncoder; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.ResourceUtils2; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ReactiveHttpOutputMessage; +import org.springframework.http.ZeroCopyHttpOutputMessage; +import org.springframework.http.support.MediaTypeUtils; +import org.springframework.util.MimeTypeUtils2; + +/** + * Implementation of {@link HttpMessageConverter} that can read and write + * {@link Resource Resources} and supports byte range requests. + ** + * @author Arjen Poutsma + */ +public class ResourceHttpMessageConverter extends CodecHttpMessageConverter { + + public ResourceHttpMessageConverter() { + super(new ResourceEncoder(), new ResourceDecoder()); + } + + public ResourceHttpMessageConverter(int bufferSize) { + super(new ResourceEncoder(bufferSize), new ResourceDecoder()); + } + + @Override + public Mono write(Publisher inputStream, + ResolvableType type, MediaType contentType, + ReactiveHttpOutputMessage outputMessage) { + return Mono.from(Flux.from(inputStream). + take(1). + concatMap(resource -> { + HttpHeaders headers = outputMessage.getHeaders(); + addHeaders(headers, resource, contentType); + + return writeContent(resource, type, contentType, outputMessage); + })); + } + + protected void addHeaders(HttpHeaders headers, Resource resource, + MediaType contentType) { + if (headers.getContentType() == null) { + if (contentType == null || + !contentType.isConcrete() || + MediaType.APPLICATION_OCTET_STREAM.equals(contentType)) { + contentType = MimeTypeUtils2.getMimeType(resource.getFilename()). + map(MediaTypeUtils::toMediaType). + orElse(MediaType.APPLICATION_OCTET_STREAM); + } + headers.setContentType(contentType); + } + if (headers.getContentLength() < 0) { + contentLength(resource).ifPresent(headers::setContentLength); + } + } + + private Mono writeContent(Resource resource, ResolvableType type, + MediaType contentType, ReactiveHttpOutputMessage outputMessage) { + if (outputMessage instanceof ZeroCopyHttpOutputMessage) { + Optional file = getFile(resource); + if (file.isPresent()) { + ZeroCopyHttpOutputMessage zeroCopyResponse = + (ZeroCopyHttpOutputMessage) outputMessage; + + return zeroCopyResponse + .writeWith(file.get(), (long) 0, file.get().length()); + } + } + + // non-zero copy fallback, using ResourceEncoder + return super.write(Mono.just(resource), type, + outputMessage.getHeaders().getContentType(), outputMessage); + } + + private static Optional contentLength(Resource resource) { + // Don't try to determine contentLength on InputStreamResource - cannot be read afterwards... + // Note: custom InputStreamResource subclasses could provide a pre-calculated content length! + if (InputStreamResource.class != resource.getClass()) { + try { + return Optional.of(resource.contentLength()); + } + catch (IOException ignored) { + } + } + return Optional.empty(); + } + + private static Optional getFile(Resource resource) { + if (ResourceUtils2.hasFile(resource)) { + try { + return Optional.of(resource.getFile()); + } + catch (IOException ignored) { + // should not happen + } + } + return Optional.empty(); + } + + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/http/package-info.java b/spring-web-reactive/src/main/java/org/springframework/http/package-info.java new file mode 100644 index 0000000000..0f7cce528b --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/package-info.java @@ -0,0 +1,5 @@ +/** + * Contains a basic abstraction over client/server-side HTTP. This package contains + * the {@code HttpInputMessage} and {@code HttpOutputMessage} interfaces. + */ +package org.springframework.http; diff --git a/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/AbstractRequestBodyPublisher.java b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/AbstractRequestBodyPublisher.java new file mode 100644 index 0000000000..19307e24cf --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/AbstractRequestBodyPublisher.java @@ -0,0 +1,316 @@ +/* + * Copyright 2002-2016 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.reactive; + +import java.io.IOException; +import java.nio.channels.Channel; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import javax.servlet.ReadListener; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.reactivestreams.Publisher; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import reactor.core.util.BackpressureUtils; + +import org.springframework.core.io.buffer.DataBuffer; + +/** + * Abstract base class for {@code Publisher} implementations that bridge between + * event-listener APIs and Reactive Streams. Specifically, base class for the Servlet 3.1 + * and Undertow support. + * + * @author Arjen Poutsma + * @see ServletServerHttpRequest + * @see UndertowHttpHandlerAdapter + */ +abstract class AbstractRequestBodyPublisher implements Publisher { + + protected final Log logger = LogFactory.getLog(getClass()); + + private final AtomicReference state = + new AtomicReference<>(State.UNSUBSCRIBED); + + private final AtomicLong demand = new AtomicLong(); + + private Subscriber subscriber; + + @Override + public void subscribe(Subscriber subscriber) { + if (this.logger.isTraceEnabled()) { + this.logger.trace(this.state + " subscribe: " + subscriber); + } + this.state.get().subscribe(this, subscriber); + } + + /** + * Called via a listener interface to indicate that reading is possible. + * @see ReadListener#onDataAvailable() + * @see org.xnio.ChannelListener#handleEvent(Channel) + */ + protected final void onDataAvailable() { + if (this.logger.isTraceEnabled()) { + this.logger.trace(this.state + " onDataAvailable"); + } + this.state.get().onDataAvailable(this); + } + + /** + * Called via a listener interface to indicate that all data has been read. + * @see ReadListener#onAllDataRead() + * @see org.xnio.ChannelListener#handleEvent(Channel) + */ + protected final void onAllDataRead() { + if (this.logger.isTraceEnabled()) { + this.logger.trace(this.state + " onAllDataRead"); + } + this.state.get().onAllDataRead(this); + } + + /** + * Called by a listener interface to indicate that as error has occured. + * @param t the error + * @see ReadListener#onError(Throwable) + */ + protected final void onError(Throwable t) { + if (this.logger.isErrorEnabled()) { + this.logger.error(this.state + " onError: " + t, t); + } + this.state.get().onError(this, t); + } + + /** + * Reads and publishes data buffers from the input. Continues till either there is no + * more demand, or till there is no more data to be read. + * @return {@code true} if there is more demand; {@code false} otherwise + */ + private boolean readAndPublish() throws IOException { + while (hasDemand()) { + DataBuffer dataBuffer = read(); + if (dataBuffer != null) { + BackpressureUtils.getAndSub(this.demand, 1L); + this.subscriber.onNext(dataBuffer); + } + else { + return true; + } + } + return false; + } + + protected abstract void checkOnDataAvailable(); + + /** + * Reads a data buffer from the input, if possible. Returns {@code null} if a buffer + * could not be read. + * @return the data buffer that was read; or {@code null} + */ + protected abstract DataBuffer read() throws IOException; + + private boolean hasDemand() { + return this.demand.get() > 0; + } + + private boolean changeState(AbstractRequestBodyPublisher.State oldState, + AbstractRequestBodyPublisher.State newState) { + return this.state.compareAndSet(oldState, newState); + } + + private static final class RequestBodySubscription implements Subscription { + + private final AbstractRequestBodyPublisher publisher; + + public RequestBodySubscription(AbstractRequestBodyPublisher publisher) { + this.publisher = publisher; + } + + @Override + public final void request(long n) { + if (this.publisher.logger.isTraceEnabled()) { + this.publisher.logger.trace(state() + " request: " + n); + } + state().request(this.publisher, n); + } + + @Override + public final void cancel() { + if (this.publisher.logger.isTraceEnabled()) { + this.publisher.logger.trace(state() + " cancel"); + } + state().cancel(this.publisher); + } + + private AbstractRequestBodyPublisher.State state() { + return this.publisher.state.get(); + } + + } + + /** + * Represents a state for the {@link Publisher} to be in. The following figure + * indicate the four different states that exist, and the relationships between them. + * + *

+	 *       UNSUBSCRIBED
+	 *        |
+	 *        v
+	 * NO_DEMAND -------------------> DEMAND
+	 *    |    ^                      ^    |
+	 *    |    |                      |    |
+	 *    |    --------- READING <-----    |
+	 *    |                 |              |
+	 *    |                 v              |
+	 *    ------------> COMPLETED <---------
+	 * 
+ * Refer to the individual states for more information. + */ + + private enum State { + /** + * The initial unsubscribed state. Will respond to {@link + * #subscribe(AbstractRequestBodyPublisher, Subscriber)} by + * changing state to {@link #NO_DEMAND}. + */ + UNSUBSCRIBED { + @Override + void subscribe(AbstractRequestBodyPublisher publisher, + Subscriber subscriber) { + Objects.requireNonNull(subscriber); + if (publisher.changeState(this, NO_DEMAND)) { + Subscription subscription = new RequestBodySubscription( + publisher); + publisher.subscriber = subscriber; + subscriber.onSubscribe(subscription); + } + else { + throw new IllegalStateException(toString()); + } + } + }, + /** + * State that gets entered when there is no demand. Responds to {@link + * #request(AbstractRequestBodyPublisher, long)} by increasing the demand, + * changing state to {@link #DEMAND} and will check whether there + * is data available for reading. + */ + NO_DEMAND { + @Override + void request(AbstractRequestBodyPublisher publisher, long n) { + if (BackpressureUtils.checkRequest(n, publisher.subscriber)) { + BackpressureUtils.addAndGet(publisher.demand, n); + if (publisher.changeState(this, DEMAND)) { + publisher.checkOnDataAvailable(); + } + } + } + }, + /** + * State that gets entered when there is demand. Responds to + * {@link #onDataAvailable(AbstractRequestBodyPublisher)} by + * reading the available data. The state will be changed to + * {@link #NO_DEMAND} if there is no demand. + */ + DEMAND { + @Override + void onDataAvailable(AbstractRequestBodyPublisher publisher) { + if (publisher.changeState(this, READING)) { + try { + boolean demandAvailable = publisher.readAndPublish(); + if (demandAvailable) { + publisher.changeState(READING, DEMAND); + publisher.checkOnDataAvailable(); + } else { + publisher.changeState(READING, NO_DEMAND); + } + } catch (IOException ex) { + publisher.onError(ex); + } + } + } + }, + READING { + @Override + void request(AbstractRequestBodyPublisher publisher, long n) { + if (BackpressureUtils.checkRequest(n, publisher.subscriber)) { + BackpressureUtils.addAndGet(publisher.demand, n); + } + } + }, + /** + * The terminal completed state. Does not respond to any events. + */ + COMPLETED { + + @Override + void request(AbstractRequestBodyPublisher publisher, long n) { + // ignore + } + + @Override + void cancel(AbstractRequestBodyPublisher publisher) { + // ignore + } + + @Override + void onAllDataRead(AbstractRequestBodyPublisher publisher) { + // ignore + } + + @Override + void onError(AbstractRequestBodyPublisher publisher, Throwable t) { + // ignore + } + }; + + void subscribe(AbstractRequestBodyPublisher publisher, + Subscriber subscriber) { + throw new IllegalStateException(toString()); + } + + void request(AbstractRequestBodyPublisher publisher, long n) { + throw new IllegalStateException(toString()); + } + + void cancel(AbstractRequestBodyPublisher publisher) { + publisher.changeState(this, COMPLETED); + } + + void onDataAvailable(AbstractRequestBodyPublisher publisher) { + // ignore + } + + void onAllDataRead(AbstractRequestBodyPublisher publisher) { + if (publisher.changeState(this, COMPLETED)) { + if (publisher.subscriber != null) { + publisher.subscriber.onComplete(); + } + } + } + + void onError(AbstractRequestBodyPublisher publisher, Throwable t) { + if (publisher.changeState(this, COMPLETED)) { + if (publisher.subscriber != null) { + publisher.subscriber.onError(t); + } + } + } + + } +} diff --git a/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/AbstractResponseBodyProcessor.java b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/AbstractResponseBodyProcessor.java new file mode 100644 index 0000000000..e4463fb6a7 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/AbstractResponseBodyProcessor.java @@ -0,0 +1,329 @@ +/* + * Copyright 2002-2016 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.reactive; + +import java.io.IOException; +import java.nio.channels.Channel; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; +import javax.servlet.WriteListener; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.reactivestreams.Processor; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.FlushingDataBuffer; +import org.springframework.core.io.buffer.support.DataBufferUtils; +import org.springframework.util.Assert; + +/** + * Abstract base class for {@code Subscriber} implementations that bridge between + * event-listener APIs and Reactive Streams. Specifically, base class for the Servlet 3.1 + * and Undertow support. + * @author Arjen Poutsma + * @see ServletServerHttpRequest + * @see UndertowHttpHandlerAdapter + */ +abstract class AbstractResponseBodyProcessor implements Processor { + + protected final Log logger = LogFactory.getLog(getClass()); + + private final ResponseBodyWriteResultPublisher publisherDelegate = + new ResponseBodyWriteResultPublisher(); + + private final AtomicReference state = + new AtomicReference<>(State.UNSUBSCRIBED); + + private volatile DataBuffer currentBuffer; + + private volatile boolean subscriberCompleted; + + private Subscription subscription; + + // Subscriber + + @Override + public final void onSubscribe(Subscription subscription) { + if (logger.isTraceEnabled()) { + logger.trace(this.state + " onSubscribe: " + subscription); + } + this.state.get().onSubscribe(this, subscription); + } + + @Override + public final void onNext(DataBuffer dataBuffer) { + if (logger.isTraceEnabled()) { + logger.trace(this.state + " onNext: " + dataBuffer); + } + this.state.get().onNext(this, dataBuffer); + } + + @Override + public final void onError(Throwable t) { + if (logger.isErrorEnabled()) { + logger.error(this.state + " onError: " + t, t); + } + this.state.get().onError(this, t); + } + + @Override + public final void onComplete() { + if (logger.isTraceEnabled()) { + logger.trace(this.state + " onComplete"); + } + this.state.get().onComplete(this); + } + + // Publisher + + @Override + public final void subscribe(Subscriber subscriber) { + this.publisherDelegate.subscribe(subscriber); + } + + // listener methods + + /** + * Called via a listener interface to indicate that writing is possible. + * @see WriteListener#onWritePossible() + * @see org.xnio.ChannelListener#handleEvent(Channel) + */ + protected final void onWritePossible() { + this.state.get().onWritePossible(this); + } + + /** + * Called when a {@link DataBuffer} is received via {@link Subscriber#onNext(Object)} + * @param dataBuffer the buffer that was received. + */ + protected void receiveBuffer(DataBuffer dataBuffer) { + Assert.state(this.currentBuffer == null); + this.currentBuffer = dataBuffer; + } + + /** + * Called when the current buffer should be + * {@linkplain DataBufferUtils#release(DataBuffer) released}. + */ + protected void releaseBuffer() { + if (logger.isTraceEnabled()) { + logger.trace("releaseBuffer: " + this.currentBuffer); + } + DataBufferUtils.release(this.currentBuffer); + this.currentBuffer = null; + } + + /** + * Called when a {@link DataBuffer} is received via {@link Subscriber#onNext(Object)} + * or when only partial data from the {@link DataBuffer} was written. + */ + private void writeIfPossible() { + if (isWritePossible()) { + onWritePossible(); + } + } + + /** + * Called via a listener interface to determine whether writing is possible. + */ + protected boolean isWritePossible() { + return false; + } + + /** + * Writes the given data buffer to the output, indicating if the entire buffer was + * written. + * @param dataBuffer the data buffer to write + * @return {@code true} if {@code dataBuffer} was fully written and a new buffer + * can be requested; {@code false} otherwise + */ + protected abstract boolean write(DataBuffer dataBuffer) throws IOException; + + /** + * Flushes the output. + */ + protected abstract void flush() throws IOException; + + private boolean changeState(State oldState, State newState) { + return this.state.compareAndSet(oldState, newState); + } + + /** + * Represents a state for the {@link Subscriber} to be in. The following figure + * indicate the four different states that exist, and the relationships between them. + * + *
+	 *       UNSUBSCRIBED
+	 *        |
+	 *        v
+	 * REQUESTED -------------------> RECEIVED
+	 *         ^                      ^
+	 *         |                      |
+	 *         --------- WRITING <-----
+	 *                      |
+	 *                      v
+	 *                  COMPLETED
+	 * 
+ * Refer to the individual states for more information. + */ + private enum State { + + /** + * The initial unsubscribed state. Will respond to {@code onSubscribe} by + * requesting 1 buffer from the subscription, and change state to {@link + * #REQUESTED}. + */ + UNSUBSCRIBED { + @Override + void onSubscribe(AbstractResponseBodyProcessor processor, + Subscription subscription) { + Objects.requireNonNull(subscription, "Subscription cannot be null"); + if (processor.changeState(this, REQUESTED)) { + processor.subscription = subscription; + subscription.request(1); + } + else { + super.onSubscribe(processor, subscription); + } + } + }, + /** + * State that gets entered after a buffer has been + * {@linkplain Subscription#request(long) requested}. Responds to {@code onNext} + * by changing state to {@link #RECEIVED}, and responds to {@code onComplete} by + * changing state to {@link #COMPLETED}. + */ + REQUESTED { + @Override + void onNext(AbstractResponseBodyProcessor processor, DataBuffer dataBuffer) { + if (processor.changeState(this, RECEIVED)) { + processor.receiveBuffer(dataBuffer); + processor.writeIfPossible(); + } + } + + @Override + void onComplete(AbstractResponseBodyProcessor processor) { + if (processor.changeState(this, COMPLETED)) { + processor.subscriberCompleted = true; + processor.publisherDelegate.publishComplete(); + } + } + }, + /** + * State that gets entered after a buffer has been + * {@linkplain Subscriber#onNext(Object) received}. Responds to + * {@code onWritePossible} by writing the current buffer and changes + * the state to {@link #WRITING}. If it can be written completely, + * changes the state to either {@link #REQUESTED} if the subscription + * has not been completed; or {@link #COMPLETED} if it has. If it cannot + * be written completely the state will be changed to {@link #RECEIVED}. + */ + RECEIVED { + @Override + void onWritePossible(AbstractResponseBodyProcessor processor) { + if (processor.changeState(this, WRITING)) { + DataBuffer dataBuffer = processor.currentBuffer; + try { + boolean writeCompleted = processor.write(dataBuffer); + if (writeCompleted) { + if (dataBuffer instanceof FlushingDataBuffer) { + processor.flush(); + } + processor.releaseBuffer(); + if (!processor.subscriberCompleted) { + processor.changeState(WRITING, REQUESTED); + processor.subscription.request(1); + } + else { + processor.changeState(WRITING, COMPLETED); + processor.publisherDelegate.publishComplete(); + } + } + else { + processor.changeState(WRITING, RECEIVED); + processor.writeIfPossible(); + } + } + catch (IOException ex) { + processor.onError(ex); + } + } + } + + @Override + void onComplete(AbstractResponseBodyProcessor processor) { + processor.subscriberCompleted = true; + } + }, + /** + * State that gets entered after a writing of the current buffer has been + * {@code onWritePossible started}. + */ + WRITING { + @Override + void onComplete(AbstractResponseBodyProcessor processor) { + processor.subscriberCompleted = true; + } + }, + /** + * The terminal completed state. Does not respond to any events. + */ + COMPLETED { + @Override + void onNext(AbstractResponseBodyProcessor processor, DataBuffer dataBuffer) { + // ignore + } + + @Override + void onError(AbstractResponseBodyProcessor processor, Throwable t) { + // ignore + } + + @Override + void onComplete(AbstractResponseBodyProcessor processor) { + // ignore + } + }; + + void onSubscribe(AbstractResponseBodyProcessor processor, Subscription s) { + s.cancel(); + } + + void onNext(AbstractResponseBodyProcessor processor, DataBuffer dataBuffer) { + throw new IllegalStateException(toString()); + } + + void onError(AbstractResponseBodyProcessor processor, Throwable t) { + if (processor.changeState(this, COMPLETED)) { + processor.publisherDelegate.publishError(t); + } + } + + void onComplete(AbstractResponseBodyProcessor processor) { + throw new IllegalStateException(toString()); + } + void onWritePossible(AbstractResponseBodyProcessor processor) { + // ignore + } + + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/AbstractServerHttpRequest.java b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/AbstractServerHttpRequest.java new file mode 100644 index 0000000000..956e9e391d --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/AbstractServerHttpRequest.java @@ -0,0 +1,121 @@ +/* + * Copyright 2002-2015 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.reactive; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.http.HttpCookie; +import org.springframework.http.HttpHeaders; +import org.springframework.util.CollectionUtils; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; + +/** + * Common base class for {@link ServerHttpRequest} implementations. + * + * @author Rossen Stoyanchev + */ +public abstract class AbstractServerHttpRequest implements ServerHttpRequest { + + private static final Pattern QUERY_PATTERN = Pattern.compile("([^&=]+)(=?)([^&]+)?"); + + + private URI uri; + + private MultiValueMap queryParams; + + private HttpHeaders headers; + + private MultiValueMap cookies; + + + @Override + public URI getURI() { + if (this.uri == null) { + try { + this.uri = initUri(); + } + catch (URISyntaxException ex) { + throw new IllegalStateException("Could not get URI: " + ex.getMessage(), ex); + } + } + return this.uri; + } + + /** + * Initialize a URI that represents the request. Invoked lazily on the first + * call to {@link #getURI()} and then cached. + * @throws URISyntaxException + */ + protected abstract URI initUri() throws URISyntaxException; + + @Override + public MultiValueMap getQueryParams() { + if (this.queryParams == null) { + this.queryParams = CollectionUtils.unmodifiableMultiValueMap(initQueryParams()); + } + return this.queryParams; + } + + protected MultiValueMap initQueryParams() { + MultiValueMap queryParams = new LinkedMultiValueMap<>(); + String query = getURI().getRawQuery(); + if (query != null) { + Matcher matcher = QUERY_PATTERN.matcher(query); + while (matcher.find()) { + String name = matcher.group(1); + String eq = matcher.group(2); + String value = matcher.group(3); + value = (value != null ? value : (StringUtils.hasLength(eq) ? "" : null)); + queryParams.add(name, value); + } + } + return queryParams; + } + + @Override + public HttpHeaders getHeaders() { + if (this.headers == null) { + this.headers = HttpHeaders.readOnlyHttpHeaders(initHeaders()); + } + return this.headers; + } + + /** + * Initialize the headers from the underlying request. Invoked lazily on the + * first call to {@link #getHeaders()} and then cached. + */ + protected abstract HttpHeaders initHeaders(); + + @Override + public MultiValueMap getCookies() { + if (this.cookies == null) { + this.cookies = CollectionUtils.unmodifiableMultiValueMap(initCookies()); + } + return this.cookies; + } + + /** + * Initialize the cookies from the underlying request. Invoked lazily on the + * first access to cookies via {@link #getHeaders()} and then cached. + */ + protected abstract MultiValueMap initCookies(); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/AbstractServerHttpResponse.java b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/AbstractServerHttpResponse.java new file mode 100644 index 0000000000..a19b875c43 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/AbstractServerHttpResponse.java @@ -0,0 +1,186 @@ +/* + * Copyright 2002-2016 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.reactive; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Mono; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + + +/** + * Base class for {@link ServerHttpResponse} implementations. + * + * @author Rossen Stoyanchev + * @author Sebastien Deleuze + */ +public abstract class AbstractServerHttpResponse implements ServerHttpResponse { + + private Log logger = LogFactory.getLog(getClass()); + + private static final int STATE_NEW = 1; + + private static final int STATE_COMMITTING = 2; + + private static final int STATE_COMMITTED = 3; + + + private final DataBufferFactory dataBufferFactory; + + + private HttpStatus statusCode; + + private final HttpHeaders headers; + + private final MultiValueMap cookies; + + private final List>> beforeCommitActions = new ArrayList<>(4); + + private final AtomicInteger state = new AtomicInteger(STATE_NEW); + + + public AbstractServerHttpResponse(DataBufferFactory dataBufferFactory) { + Assert.notNull(dataBufferFactory, "'dataBufferFactory' must not be null"); + + this.dataBufferFactory = dataBufferFactory; + this.headers = new HttpHeaders(); + this.cookies = new LinkedMultiValueMap(); + } + + + @Override + public final DataBufferFactory bufferFactory() { + return this.dataBufferFactory; + } + + + @Override + public boolean setStatusCode(HttpStatus statusCode) { + Assert.notNull(statusCode); + if (STATE_NEW == this.state.get()) { + this.statusCode = statusCode; + return true; + } + else if (logger.isDebugEnabled()) { + logger.debug("Can't set the status " + statusCode.toString() + + " because the HTTP response has already been committed"); + } + return false; + } + + @Override + public HttpStatus getStatusCode() { + return this.statusCode; + } + + @Override + public HttpHeaders getHeaders() { + if (STATE_COMMITTED == this.state.get()) { + return HttpHeaders.readOnlyHttpHeaders(this.headers); + } + else { + return this.headers; + } + } + + @Override + public MultiValueMap getCookies() { + if (STATE_COMMITTED == this.state.get()) { + return CollectionUtils.unmodifiableMultiValueMap(this.cookies); + } + return this.cookies; + } + + @Override + public void beforeCommit(Supplier> action) { + Assert.notNull(action); + this.beforeCommitActions.add(action); + } + + @Override + public Mono writeWith(Publisher publisher) { + return new ChannelSendOperator<>(publisher, writePublisher -> + applyBeforeCommit().then(() -> writeWithInternal(writePublisher))); + } + + protected Mono applyBeforeCommit() { + Mono mono = Mono.empty(); + if (this.state.compareAndSet(STATE_NEW, STATE_COMMITTING)) { + for (Supplier> action : this.beforeCommitActions) { + mono = mono.then(action); + } + mono = mono.otherwise(ex -> { + // Ignore errors from beforeCommit actions + return Mono.empty(); + }); + mono = mono.then(() -> { + this.state.set(STATE_COMMITTED); + writeStatusCode(); + writeHeaders(); + writeCookies(); + return Mono.empty(); + }); + } + return mono; + } + + /** + * Implement this method to write to the underlying the response. + * @param body the publisher to write with + */ + protected abstract Mono writeWithInternal(Publisher body); + + /** + * Implement this method to write the status code to the underlying response. + * This method is called once only. + */ + protected abstract void writeStatusCode(); + + /** + * Implement this method to apply header changes from {@link #getHeaders()} + * to the underlying response. This method is called once only. + */ + protected abstract void writeHeaders(); + + /** + * Implement this method to add cookies from {@link #getHeaders()} to the + * underlying response. This method is called once only. + */ + protected abstract void writeCookies(); + + + @Override + public Mono setComplete() { + return applyBeforeCommit(); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ChannelSendOperator.java b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ChannelSendOperator.java new file mode 100644 index 0000000000..39ded1e28f --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ChannelSendOperator.java @@ -0,0 +1,241 @@ +/* + * Copyright 2002-2015 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.reactive; + +import java.util.function.Function; + +import org.reactivestreams.Publisher; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import reactor.core.publisher.MonoSource; +import reactor.core.subscriber.SubscriberBarrier; +import reactor.core.util.EmptySubscription; + +import org.springframework.util.Assert; + +/** + * Given a write function that accepts a source {@code Publisher} to write + * with and returns {@code Publisher} for the result, this operator helps + * to defer the invocation of the write function, until we know if the source + * publisher will begin publishing without an error. If the first emission is + * an error, the write function is bypassed, and the error is sent directly + * through the result publisher. Otherwise the write function is invoked. + * + * @author Rossen Stoyanchev + * @author Stephane Maldini + */ +public class ChannelSendOperator extends MonoSource { + + private final Function, Publisher> writeFunction; + + + public ChannelSendOperator(Publisher source, + Function, Publisher> writeFunction) { + super(source); + this.writeFunction = writeFunction; + } + + @Override + public void subscribe(Subscriber s) { + source.subscribe(new WriteWithBarrier(s)); + } + + private class WriteWithBarrier extends SubscriberBarrier implements Publisher { + + /** + * We've at at least one emission, we've called the write function, the write + * subscriber has subscribed and cached signals have been emitted to it. + * We're now simply passing data through to the write subscriber. + **/ + private boolean readyToWrite = false; + + /** No emission from upstream yet */ + private boolean beforeFirstEmission = true; + + /** Cached signal before readyToWrite */ + private T item; + + /** Cached 1st/2nd signal before readyToWrite */ + private Throwable error; + + /** Cached 1st/2nd signal before readyToWrite */ + private boolean completed = false; + + /** The actual writeSubscriber vs the downstream completion subscriber */ + private Subscriber writeSubscriber; + + + public WriteWithBarrier(Subscriber subscriber) { + super(subscriber); + } + + + @Override + protected void doOnSubscribe(Subscription subscription) { + super.doOnSubscribe(subscription); + super.upstream() + .request(1); // bypass doRequest + } + + @Override + public void doNext(T item) { + if (this.readyToWrite) { + this.writeSubscriber.onNext(item); + return; + } + synchronized (this) { + if (this.readyToWrite) { + this.writeSubscriber.onNext(item); + } + else if (this.beforeFirstEmission) { + this.item = item; + this.beforeFirstEmission = false; + writeFunction.apply(this).subscribe(new DownstreamBridge(downstream())); + } + else { + subscription.cancel(); + downstream().onError(new IllegalStateException("Unexpected item.")); + } + } + } + + @Override + public void doError(Throwable ex) { + if (this.readyToWrite) { + this.writeSubscriber.onError(ex); + return; + } + synchronized (this) { + if (this.readyToWrite) { + this.writeSubscriber.onError(ex); + } + else if (this.beforeFirstEmission) { + this.beforeFirstEmission = false; + downstream().onError(ex); + } + else { + this.error = ex; + } + } + } + + @Override + public void doComplete() { + if (this.readyToWrite) { + this.writeSubscriber.onComplete(); + return; + } + synchronized (this) { + if (this.readyToWrite) { + this.writeSubscriber.onComplete(); + } + else if (this.beforeFirstEmission) { + this.completed = true; + this.beforeFirstEmission = false; + writeFunction.apply(this).subscribe(new DownstreamBridge(downstream())); + } + else { + this.completed = true; + } + } + } + + @Override + public void subscribe(Subscriber writeSubscriber) { + synchronized (this) { + Assert.isNull(this.writeSubscriber, "Only one writeSubscriber supported."); + this.writeSubscriber = writeSubscriber; + + if (this.error != null || this.completed) { + this.writeSubscriber.onSubscribe(EmptySubscription.INSTANCE); + emitCachedSignals(); + } + else { + this.writeSubscriber.onSubscribe(this); + } + } + } + + /** + * Emit cached signals to the write subscriber. + * @return true if no more signals expected + */ + private boolean emitCachedSignals() { + if (this.item != null) { + this.writeSubscriber.onNext(this.item); + } + if (this.error != null) { + this.writeSubscriber.onError(this.error); + return true; + } + if (this.completed) { + this.writeSubscriber.onComplete(); + return true; + } + return false; + } + + @Override + protected void doRequest(long n) { + if (readyToWrite) { + super.doRequest(n); + return; + } + synchronized (this) { + if (this.writeSubscriber != null) { + readyToWrite = true; + if (emitCachedSignals()) { + return; + } + n--; + if (n == 0) { + return; + } + super.doRequest(n); + } + } + } + } + + private class DownstreamBridge implements Subscriber { + + private final Subscriber downstream; + + public DownstreamBridge(Subscriber downstream) { + this.downstream = downstream; + } + + @Override + public void onSubscribe(Subscription subscription) { + subscription.request(Long.MAX_VALUE); + } + + @Override + public void onNext(Void aVoid) { + } + + @Override + public void onError(Throwable ex) { + this.downstream.onError(ex); + } + + @Override + public void onComplete() { + this.downstream.onComplete(); + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/HttpHandler.java b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/HttpHandler.java new file mode 100644 index 0000000000..7ee64f161d --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/HttpHandler.java @@ -0,0 +1,37 @@ +/* + * Copyright 2002-2015 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.reactive; + +import reactor.core.publisher.Mono; + +/** + * Contract for handling HTTP requests in a non-blocking way. + * + * @author Arjen Poutsma + */ +public interface HttpHandler { + + /** + * Handle the given request and generate a response. + * + * @param request current HTTP request. + * @param response current HTTP response. + * @return {@code Mono} to indicate when request handling is complete. + */ + Mono handle(ServerHttpRequest request, ServerHttpResponse response); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ReactorHttpHandlerAdapter.java b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ReactorHttpHandlerAdapter.java new file mode 100644 index 0000000000..a582963d4b --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ReactorHttpHandlerAdapter.java @@ -0,0 +1,52 @@ +/* + * Copyright 2002-2016 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.reactive; + +import io.netty.buffer.ByteBuf; +import reactor.core.publisher.Mono; +import reactor.io.ipc.ChannelHandler; +import reactor.io.netty.http.HttpChannel; + +import org.springframework.core.io.buffer.NettyDataBufferFactory; +import org.springframework.util.Assert; + +/** + * @author Stephane Maldini + */ +public class ReactorHttpHandlerAdapter + implements ChannelHandler { + + private final HttpHandler httpHandler; + + public ReactorHttpHandlerAdapter(HttpHandler httpHandler) { + Assert.notNull(httpHandler, "'httpHandler' is required."); + this.httpHandler = httpHandler; + } + + @Override + public Mono apply(HttpChannel channel) { + NettyDataBufferFactory dataBufferFactory = + new NettyDataBufferFactory(channel.delegate().alloc()); + + ReactorServerHttpRequest adaptedRequest = + new ReactorServerHttpRequest(channel, dataBufferFactory); + ReactorServerHttpResponse adaptedResponse = + new ReactorServerHttpResponse(channel, dataBufferFactory); + return this.httpHandler.handle(adaptedRequest, adaptedResponse); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ReactorServerHttpRequest.java b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ReactorServerHttpRequest.java new file mode 100644 index 0000000000..8defae9cb6 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ReactorServerHttpRequest.java @@ -0,0 +1,95 @@ +/* + * Copyright 2002-2016 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.reactive; + +import java.net.URI; +import java.net.URISyntaxException; + +import io.netty.handler.codec.http.cookie.Cookie; +import reactor.core.publisher.Flux; +import reactor.io.netty.http.HttpChannel; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.NettyDataBufferFactory; +import org.springframework.http.HttpCookie; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.util.Assert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +/** + * Adapt {@link ServerHttpRequest} to the Reactor Net {@link HttpChannel}. + * + * @author Stephane Maldini + */ +public class ReactorServerHttpRequest extends AbstractServerHttpRequest { + + private final HttpChannel channel; + + private final NettyDataBufferFactory dataBufferFactory; + + public ReactorServerHttpRequest(HttpChannel request, + NettyDataBufferFactory dataBufferFactory) { + Assert.notNull("'request' must not be null"); + Assert.notNull(dataBufferFactory, "'dataBufferFactory' must not be null"); + this.channel = request; + this.dataBufferFactory = dataBufferFactory; + } + + + public HttpChannel getReactorChannel() { + return this.channel; + } + + @Override + public HttpMethod getMethod() { + return HttpMethod.valueOf(this.channel.method().name()); + } + + @Override + protected URI initUri() throws URISyntaxException { + return new URI(this.channel.uri()); + } + + @Override + protected HttpHeaders initHeaders() { + HttpHeaders headers = new HttpHeaders(); + for (String name : this.channel.headers().names()) { + headers.put(name, this.channel.headers().getAll(name)); + } + return headers; + } + + @Override + protected MultiValueMap initCookies() { + MultiValueMap cookies = new LinkedMultiValueMap<>(); + for (CharSequence name : this.channel.cookies().keySet()) { + for (Cookie cookie : this.channel.cookies().get(name)) { + HttpCookie httpCookie = new HttpCookie(name.toString(), cookie.value()); + cookies.add(name.toString(), httpCookie); + } + } + return cookies; + } + + @Override + public Flux getBody() { + return this.channel.receive().retain().map(this.dataBufferFactory::wrap); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ReactorServerHttpResponse.java b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ReactorServerHttpResponse.java new file mode 100644 index 0000000000..55d1a26265 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ReactorServerHttpResponse.java @@ -0,0 +1,125 @@ +/* + * Copyright 2002-2016 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.reactive; + +import java.io.File; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.codec.http.cookie.Cookie; +import io.netty.handler.codec.http.cookie.DefaultCookie; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.io.netty.http.HttpChannel; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.io.buffer.FlushingDataBuffer; +import org.springframework.core.io.buffer.NettyDataBuffer; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ZeroCopyHttpOutputMessage; +import org.springframework.util.Assert; + +/** + * Adapt {@link ServerHttpResponse} to the Reactor Net {@link HttpChannel}. + * + * @author Stephane Maldini + * @author Rossen Stoyanchev + */ +public class ReactorServerHttpResponse extends AbstractServerHttpResponse + implements ZeroCopyHttpOutputMessage { + + private final HttpChannel channel; + + + public ReactorServerHttpResponse(HttpChannel response, + DataBufferFactory dataBufferFactory) { + super(dataBufferFactory); + Assert.notNull("'response' must not be null."); + this.channel = response; + } + + + public HttpChannel getReactorChannel() { + return this.channel; + } + + + @Override + protected void writeStatusCode() { + HttpStatus statusCode = this.getStatusCode(); + if (statusCode != null) { + getReactorChannel().status(HttpResponseStatus.valueOf(statusCode.value())); + } + } + + @Override + protected Mono writeWithInternal(Publisher publisher) { + return Flux.from(publisher) + .window() + .concatMap(w -> this.channel.send(w + .takeUntil(db -> db instanceof FlushingDataBuffer) + .map(this::toByteBuf))) + .then(); + } + + @Override + protected void writeHeaders() { + for (String name : getHeaders().keySet()) { + for (String value : getHeaders().get(name)) { + this.channel.responseHeaders().add(name, value); + } + } + } + + @Override + protected void writeCookies() { + for (String name : getCookies().keySet()) { + for (ResponseCookie httpCookie : getCookies().get(name)) { + Cookie cookie = new DefaultCookie(name, httpCookie.getValue()); + if (!httpCookie.getMaxAge().isNegative()) { + cookie.setMaxAge(httpCookie.getMaxAge().getSeconds()); + } + httpCookie.getDomain().ifPresent(cookie::setDomain); + httpCookie.getPath().ifPresent(cookie::setPath); + cookie.setSecure(httpCookie.isSecure()); + cookie.setHttpOnly(httpCookie.isHttpOnly()); + this.channel.addResponseCookie(cookie); + } + } + } + + private ByteBuf toByteBuf(DataBuffer buffer) { + if (buffer instanceof NettyDataBuffer) { + return ((NettyDataBuffer) buffer).getNativeBuffer(); + } + else { + return Unpooled.wrappedBuffer(buffer.asByteBuffer()); + } + } + + @Override + public Mono writeWith(File file, long position, long count) { + return applyBeforeCommit().then(() -> { + return this.channel.sendFile(file, position, count); + }); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ResponseBodyWriteResultPublisher.java b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ResponseBodyWriteResultPublisher.java new file mode 100644 index 0000000000..eeaab20ed0 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ResponseBodyWriteResultPublisher.java @@ -0,0 +1,211 @@ +/* + * Copyright 2002-2016 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.reactive; + +import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.reactivestreams.Publisher; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import reactor.core.util.BackpressureUtils; + +/** + * Publisher returned from {@link ServerHttpResponse#writeWith(Publisher)}. + * @author Arjen Poutsma + */ +class ResponseBodyWriteResultPublisher implements Publisher { + + private static final Log logger = + LogFactory.getLog(ResponseBodyWriteResultPublisher.class); + + private final AtomicReference state = + new AtomicReference<>(State.UNSUBSCRIBED); + + private Subscriber subscriber; + + private volatile boolean publisherCompleted; + + private volatile Throwable publisherError; + + @Override + public final void subscribe(Subscriber subscriber) { + if (logger.isTraceEnabled()) { + logger.trace(this.state + " subscribe: " + subscriber); + } + this.state.get().subscribe(this, subscriber); + } + + private boolean changeState(State oldState, State newState) { + return this.state.compareAndSet(oldState, newState); + } + + /** + * Publishes the complete signal to the subscriber of this publisher. + */ + public void publishComplete() { + if (logger.isTraceEnabled()) { + logger.trace(this.state + " publishComplete"); + } + this.state.get().publishComplete(this); + } + + /** + * Publishes the given error signal to the subscriber of this publisher. + */ + public void publishError(Throwable t) { + if (logger.isTraceEnabled()) { + logger.trace(this.state + " publishError: " + t); + } + this.state.get().publishError(this, t); + } + + private static final class ResponseBodyWriteResultSubscription + implements Subscription { + + private final ResponseBodyWriteResultPublisher publisher; + + public ResponseBodyWriteResultSubscription( + ResponseBodyWriteResultPublisher publisher) { + this.publisher = publisher; + } + + @Override + public final void request(long n) { + if (logger.isTraceEnabled()) { + logger.trace(state() + " request: " + n); + } + state().request(this.publisher, n); + } + + @Override + public final void cancel() { + if (logger.isTraceEnabled()) { + logger.trace(state() + " cancel"); + } + state().cancel(this.publisher); + } + + private State state() { + return this.publisher.state.get(); + } + + } + + private enum State { + UNSUBSCRIBED { + @Override + void subscribe(ResponseBodyWriteResultPublisher publisher, + Subscriber subscriber) { + Objects.requireNonNull(subscriber); + if (publisher.changeState(this, SUBSCRIBED)) { + Subscription subscription = + new ResponseBodyWriteResultSubscription(publisher); + publisher.subscriber = subscriber; + subscriber.onSubscribe(subscription); + if (publisher.publisherCompleted) { + publisher.publishComplete(); + } + else if (publisher.publisherError != null) { + publisher.publishError(publisher.publisherError); + } + } + else { + throw new IllegalStateException(toString()); + } + } + + @Override + void publishComplete(ResponseBodyWriteResultPublisher publisher) { + publisher.publisherCompleted = true; + } + + @Override + void publishError(ResponseBodyWriteResultPublisher publisher, Throwable t) { + publisher.publisherError = t; + } + }, + SUBSCRIBED { + @Override + void request(ResponseBodyWriteResultPublisher publisher, long n) { + BackpressureUtils.checkRequest(n, publisher.subscriber); + } + + @Override + void publishComplete(ResponseBodyWriteResultPublisher publisher) { + if (publisher.changeState(this, COMPLETED)) { + publisher.subscriber.onComplete(); + } + } + + @Override + void publishError(ResponseBodyWriteResultPublisher publisher, Throwable t) { + if (publisher.changeState(this, COMPLETED)) { + publisher.subscriber.onError(t); + } + } + + }, + COMPLETED { + @Override + void request(ResponseBodyWriteResultPublisher publisher, long n) { + // ignore + } + + @Override + void cancel(ResponseBodyWriteResultPublisher publisher) { + // ignore + } + + @Override + void publishComplete(ResponseBodyWriteResultPublisher publisher) { + // ignore + } + + @Override + void publishError(ResponseBodyWriteResultPublisher publisher, Throwable t) { + // ignore + } + }; + + void subscribe(ResponseBodyWriteResultPublisher publisher, + Subscriber subscriber) { + throw new IllegalStateException(toString()); + } + + void request(ResponseBodyWriteResultPublisher publisher, long n) { + throw new IllegalStateException(toString()); + } + + void cancel(ResponseBodyWriteResultPublisher publisher) { + publisher.changeState(this, COMPLETED); + } + + void publishComplete(ResponseBodyWriteResultPublisher publisher) { + throw new IllegalStateException(toString()); + } + + void publishError(ResponseBodyWriteResultPublisher publisher, Throwable t) { + throw new IllegalStateException(toString()); + } + + } + + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/RxNettyHttpHandlerAdapter.java b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/RxNettyHttpHandlerAdapter.java new file mode 100644 index 0000000000..4d7cc5a69c --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/RxNettyHttpHandlerAdapter.java @@ -0,0 +1,55 @@ +/* + * Copyright 2002-2016 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.reactive; + +import io.netty.buffer.ByteBuf; +import io.reactivex.netty.protocol.http.server.HttpServerRequest; +import io.reactivex.netty.protocol.http.server.HttpServerResponse; +import io.reactivex.netty.protocol.http.server.RequestHandler; +import org.reactivestreams.Publisher; +import reactor.core.converter.RxJava1ObservableConverter; +import rx.Observable; + +import org.springframework.core.io.buffer.NettyDataBufferFactory; +import org.springframework.util.Assert; + +/** + * @author Rossen Stoyanchev + */ +public class RxNettyHttpHandlerAdapter implements RequestHandler { + + private final HttpHandler httpHandler; + + public RxNettyHttpHandlerAdapter(HttpHandler httpHandler) { + Assert.notNull(httpHandler, "'httpHandler' is required"); + this.httpHandler = httpHandler; + } + + @Override + public Observable handle(HttpServerRequest request, HttpServerResponse response) { + NettyDataBufferFactory dataBufferFactory = + new NettyDataBufferFactory(response.unsafeNettyChannel().alloc()); + + RxNettyServerHttpRequest adaptedRequest = + new RxNettyServerHttpRequest(request, dataBufferFactory); + RxNettyServerHttpResponse adaptedResponse = + new RxNettyServerHttpResponse(response, dataBufferFactory); + Publisher result = this.httpHandler.handle(adaptedRequest, adaptedResponse); + return RxJava1ObservableConverter.fromPublisher(result); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/RxNettyServerHttpRequest.java b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/RxNettyServerHttpRequest.java new file mode 100644 index 0000000000..4d77f6b484 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/RxNettyServerHttpRequest.java @@ -0,0 +1,100 @@ +/* + * Copyright 2002-2016 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.reactive; + +import java.net.URI; +import java.net.URISyntaxException; + +import io.netty.buffer.ByteBuf; +import io.netty.handler.codec.http.cookie.Cookie; +import io.reactivex.netty.protocol.http.server.HttpServerRequest; +import reactor.core.converter.RxJava1ObservableConverter; +import reactor.core.publisher.Flux; +import rx.Observable; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.NettyDataBufferFactory; +import org.springframework.http.HttpCookie; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.util.Assert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +/** + * Adapt {@link ServerHttpRequest} to the RxNetty {@link HttpServerRequest}. + * + * @author Rossen Stoyanchev + * @author Stephane Maldini + */ +public class RxNettyServerHttpRequest extends AbstractServerHttpRequest { + + private final HttpServerRequest request; + + private final NettyDataBufferFactory dataBufferFactory; + + public RxNettyServerHttpRequest(HttpServerRequest request, + NettyDataBufferFactory dataBufferFactory) { + Assert.notNull("'request', request must not be null"); + Assert.notNull(dataBufferFactory, "'dataBufferFactory' must not be null"); + this.dataBufferFactory = dataBufferFactory; + this.request = request; + } + + + public HttpServerRequest getRxNettyRequest() { + return this.request; + } + + @Override + public HttpMethod getMethod() { + return HttpMethod.valueOf(this.request.getHttpMethod().name()); + } + + @Override + protected URI initUri() throws URISyntaxException { + return new URI(this.request.getUri()); + } + + @Override + protected HttpHeaders initHeaders() { + HttpHeaders headers = new HttpHeaders(); + for (String name : this.request.getHeaderNames()) { + headers.put(name, this.request.getAllHeaderValues(name)); + } + return headers; + } + + @Override + protected MultiValueMap initCookies() { + MultiValueMap cookies = new LinkedMultiValueMap<>(); + for (String name : this.request.getCookies().keySet()) { + for (Cookie cookie : this.request.getCookies().get(name)) { + HttpCookie httpCookie = new HttpCookie(name, cookie.value()); + cookies.add(name, httpCookie); + } + } + return cookies; + } + + @Override + public Flux getBody() { + Observable content = this.request.getContent().map(dataBufferFactory::wrap); + return RxJava1ObservableConverter.toPublisher(content); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/RxNettyServerHttpResponse.java b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/RxNettyServerHttpResponse.java new file mode 100644 index 0000000000..db90248a12 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/RxNettyServerHttpResponse.java @@ -0,0 +1,147 @@ +/* + * Copyright 2002-2016 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.reactive; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.CompositeByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.codec.http.cookie.Cookie; +import io.netty.handler.codec.http.cookie.DefaultCookie; +import io.reactivex.netty.protocol.http.server.HttpServerResponse; +import org.reactivestreams.Publisher; +import reactor.core.converter.RxJava1ObservableConverter; +import reactor.core.publisher.Mono; +import rx.Observable; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.FlushingDataBuffer; +import org.springframework.core.io.buffer.NettyDataBuffer; +import org.springframework.core.io.buffer.NettyDataBufferFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; +import org.springframework.util.Assert; + +/** + * Adapt {@link ServerHttpResponse} to the RxNetty {@link HttpServerResponse}. + * + * @author Rossen Stoyanchev + * @author Stephane Maldini + */ +public class RxNettyServerHttpResponse extends AbstractServerHttpResponse { + + private final HttpServerResponse response; + + + public RxNettyServerHttpResponse(HttpServerResponse response, + NettyDataBufferFactory dataBufferFactory) { + super(dataBufferFactory); + Assert.notNull("'response', response must not be null."); + + this.response = response; + } + + + public HttpServerResponse getRxNettyResponse() { + return this.response; + } + + + @Override + protected void writeStatusCode() { + HttpStatus statusCode = this.getStatusCode(); + if (statusCode != null) { + this.response.setStatus(HttpResponseStatus.valueOf(statusCode.value())); + } + } + + @Override + protected Mono writeWithInternal(Publisher body) { + Observable content = RxJava1ObservableConverter.fromPublisher(body).map(this::toByteBuf); + return RxJava1ObservableConverter.toPublisher(this.response.write(content, bb -> bb instanceof FlushingByteBuf)).then(); + } + + private ByteBuf toByteBuf(DataBuffer buffer) { + ByteBuf byteBuf = (buffer instanceof NettyDataBuffer ? ((NettyDataBuffer) buffer).getNativeBuffer() : Unpooled.wrappedBuffer(buffer.asByteBuffer())); + return (buffer instanceof FlushingDataBuffer ? new FlushingByteBuf(byteBuf) : byteBuf); + } + + @Override + protected void writeHeaders() { + for (String name : getHeaders().keySet()) { + for (String value : getHeaders().get(name)) + this.response.addHeader(name, value); + } + } + + @Override + protected void writeCookies() { + for (String name : getCookies().keySet()) { + for (ResponseCookie httpCookie : getCookies().get(name)) { + Cookie cookie = new DefaultCookie(name, httpCookie.getValue()); + if (!httpCookie.getMaxAge().isNegative()) { + cookie.setMaxAge(httpCookie.getMaxAge().getSeconds()); + } + httpCookie.getDomain().ifPresent(cookie::setDomain); + httpCookie.getPath().ifPresent(cookie::setPath); + cookie.setSecure(httpCookie.isSecure()); + cookie.setHttpOnly(httpCookie.isHttpOnly()); + this.response.addCookie(cookie); + } + } + } + + private class FlushingByteBuf extends CompositeByteBuf { + + public FlushingByteBuf(ByteBuf byteBuf) { + super(byteBuf.alloc(), byteBuf.isDirect(), 1); + this.addComponent(true, byteBuf); + } + } + +/* + While the underlying implementation of {@link ZeroCopyHttpOutputMessage} seems to + work; it does bypass {@link #applyBeforeCommit} and more importantly it doesn't change + its {@linkplain #state()). Therefore it's commented out, for now. + + We should revisit this code once + https://github.com/ReactiveX/RxNetty/issues/194 has been fixed. + + + @Override + public Mono writeWith(File file, long position, long count) { + Channel channel = this.response.unsafeNettyChannel(); + + HttpResponse httpResponse = + new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK); + io.netty.handler.codec.http.HttpHeaders headers = httpResponse.headers(); + + for (Map.Entry> header : getHeaders().entrySet()) { + String headerName = header.getKey(); + for (String headerValue : header.getValue()) { + headers.add(headerName, headerValue); + } + } + Mono responseWrite = MonoChannelFuture.from(channel.write(httpResponse)); + + FileRegion fileRegion = new DefaultFileRegion(file, position, count); + Mono fileWrite = MonoChannelFuture.from(channel.writeAndFlush(fileRegion)); + + return Flux.concat(applyBeforeCommit(), responseWrite, fileWrite).then(); + } +*/ +} \ No newline at end of file diff --git a/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ServerHttpRequest.java b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ServerHttpRequest.java new file mode 100644 index 0000000000..377721d566 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ServerHttpRequest.java @@ -0,0 +1,41 @@ +/* + * Copyright 2002-2016 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.reactive; + +import org.springframework.http.HttpCookie; +import org.springframework.http.HttpRequest; +import org.springframework.http.ReactiveHttpInputMessage; +import org.springframework.util.MultiValueMap; + +/** + * Represents a reactive server-side HTTP request + * + * @author Arjen Poutsma + */ +public interface ServerHttpRequest extends HttpRequest, ReactiveHttpInputMessage { + + /** + * Return a read-only map with parsed and decoded query parameter values. + */ + MultiValueMap getQueryParams(); + + /** + * Return a read-only map of cookies sent by the client. + */ + MultiValueMap getCookies(); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ServerHttpResponse.java b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ServerHttpResponse.java new file mode 100644 index 0000000000..777b36defa --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ServerHttpResponse.java @@ -0,0 +1,63 @@ +/* + * Copyright 2002-2016 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.reactive; + +import reactor.core.publisher.Mono; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ReactiveHttpOutputMessage; +import org.springframework.http.ResponseCookie; +import org.springframework.util.MultiValueMap; + +/** + * Represents a reactive server-side HTTP response. + * + * @author Arjen Poutsma + * @author Sebastien Deleuze + */ +public interface ServerHttpResponse extends ReactiveHttpOutputMessage { + + /** + * Set the HTTP status code of the response. + * @param status the HTTP status as an {@link HttpStatus} enum value + * @return {@code false} if the status code has not been set because the HTTP response + * is already committed, {@code true} if it has been set correctly. + */ + boolean setStatusCode(HttpStatus status); + + /** + * Return the HTTP status code or {@code null} if not set. + */ + HttpStatus getStatusCode(); + + /** + * Return a mutable map with the cookies to send to the server. + */ + MultiValueMap getCookies(); + + /** + * Indicate that request handling is complete, allowing for any cleanup or + * end-of-processing tasks to be performed such as applying header changes + * made via {@link #getHeaders()} to the underlying server response (if not + * applied already). + *

This method should be automatically invoked at the end of request + * processing so typically applications should not have to invoke it. + * If invoked multiple times it should have no side effects. + */ + Mono setComplete(); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ServletHttpHandlerAdapter.java b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ServletHttpHandlerAdapter.java new file mode 100644 index 0000000000..e25a03cc5e --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ServletHttpHandlerAdapter.java @@ -0,0 +1,124 @@ +/* + * Copyright 2002-2016 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.reactive; + +import java.io.IOException; +import javax.servlet.AsyncContext; +import javax.servlet.ServletException; +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; + +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.util.Assert; + +/** + * @author Arjen Poutsma + * @author Rossen Stoyanchev + */ +@WebServlet(asyncSupported = true) +public class ServletHttpHandlerAdapter extends HttpServlet { + + private static final int DEFAULT_BUFFER_SIZE = 8192; + + private static Log logger = LogFactory.getLog(ServletHttpHandlerAdapter.class); + + private HttpHandler handler; + + // Servlet is based on blocking I/O, hence the usage of non-direct, heap-based buffers + // (i.e. 'false' as constructor argument) + private DataBufferFactory dataBufferFactory = new DefaultDataBufferFactory(false); + + private int bufferSize = DEFAULT_BUFFER_SIZE; + + public void setHandler(HttpHandler handler) { + Assert.notNull(handler, "'handler' must not be null"); + this.handler = handler; + } + + public void setDataBufferFactory(DataBufferFactory dataBufferFactory) { + Assert.notNull(dataBufferFactory, "'dataBufferFactory' must not be null"); + this.dataBufferFactory = dataBufferFactory; + } + + public void setBufferSize(int bufferSize) { + Assert.isTrue(bufferSize > 0); + this.bufferSize = bufferSize; + } + + @Override + protected void service(HttpServletRequest servletRequest, + HttpServletResponse servletResponse) throws ServletException, IOException { + + AsyncContext asyncContext = servletRequest.startAsync(); + + ServletServerHttpRequest request = + new ServletServerHttpRequest(servletRequest, this.dataBufferFactory, + this.bufferSize); + + ServletServerHttpResponse response = + new ServletServerHttpResponse(servletResponse, this.dataBufferFactory, + this.bufferSize); + + HandlerResultSubscriber resultSubscriber = + new HandlerResultSubscriber(asyncContext); + + this.handler.handle(request, response).subscribe(resultSubscriber); + } + + private static class HandlerResultSubscriber implements Subscriber { + + private final AsyncContext asyncContext; + + public HandlerResultSubscriber(AsyncContext asyncContext) { + this.asyncContext = asyncContext; + } + + @Override + public void onSubscribe(Subscription subscription) { + subscription.request(Long.MAX_VALUE); + } + + @Override + public void onNext(Void aVoid) { + // no op + } + + @Override + public void onError(Throwable ex) { + logger.error("Error from request handling. Completing the request.", ex); + HttpServletResponse response = + (HttpServletResponse) this.asyncContext.getResponse(); + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + this.asyncContext.complete(); + } + + @Override + public void onComplete() { + this.asyncContext.complete(); + } + } + + +} \ No newline at end of file diff --git a/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ServletServerHttpRequest.java b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ServletServerHttpRequest.java new file mode 100644 index 0000000000..c9c7a1d1b0 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ServletServerHttpRequest.java @@ -0,0 +1,236 @@ +/* + * Copyright 2002-2016 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.reactive; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.Charset; +import java.util.Enumeration; +import java.util.Map; +import javax.servlet.ReadListener; +import javax.servlet.ServletInputStream; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; + +import reactor.core.publisher.Flux; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.http.HttpCookie; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.util.Assert; +import org.springframework.util.LinkedCaseInsensitiveMap; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; + +/** + * Adapt {@link ServerHttpRequest} to the Servlet {@link HttpServletRequest}. + * @author Rossen Stoyanchev + */ +public class ServletServerHttpRequest extends AbstractServerHttpRequest { + + private final Object bodyPublisherMonitor = new Object(); + + private volatile RequestBodyPublisher bodyPublisher; + + private final HttpServletRequest request; + + private final DataBufferFactory dataBufferFactory; + + private final int bufferSize; + + public ServletServerHttpRequest(HttpServletRequest request, + DataBufferFactory dataBufferFactory, int bufferSize) { + Assert.notNull(request, "'request' must not be null."); + Assert.notNull(dataBufferFactory, "'dataBufferFactory' must not be null"); + Assert.isTrue(bufferSize > 0); + + this.request = request; + this.dataBufferFactory = dataBufferFactory; + this.bufferSize = bufferSize; + } + + public HttpServletRequest getServletRequest() { + return this.request; + } + + @Override + public HttpMethod getMethod() { + return HttpMethod.valueOf(getServletRequest().getMethod()); + } + + @Override + protected URI initUri() throws URISyntaxException { + StringBuffer url = this.request.getRequestURL(); + String query = this.request.getQueryString(); + if (StringUtils.hasText(query)) { + url.append('?').append(query); + } + return new URI(url.toString()); + } + + @Override + protected HttpHeaders initHeaders() { + HttpHeaders headers = new HttpHeaders(); + for (Enumeration names = getServletRequest().getHeaderNames(); + names.hasMoreElements(); ) { + String name = (String) names.nextElement(); + for (Enumeration values = getServletRequest().getHeaders(name); + values.hasMoreElements(); ) { + headers.add(name, (String) values.nextElement()); + } + } + MediaType contentType = headers.getContentType(); + if (contentType == null) { + String requestContentType = getServletRequest().getContentType(); + if (StringUtils.hasLength(requestContentType)) { + contentType = MediaType.parseMediaType(requestContentType); + headers.setContentType(contentType); + } + } + if (contentType != null && contentType.getCharset() == null) { + String encoding = getServletRequest().getCharacterEncoding(); + if (StringUtils.hasLength(encoding)) { + Charset charset = Charset.forName(encoding); + Map params = new LinkedCaseInsensitiveMap<>(); + params.putAll(contentType.getParameters()); + params.put("charset", charset.toString()); + headers.setContentType( + new MediaType(contentType.getType(), contentType.getSubtype(), + params)); + } + } + if (headers.getContentLength() == -1) { + int contentLength = getServletRequest().getContentLength(); + if (contentLength != -1) { + headers.setContentLength(contentLength); + } + } + return headers; + } + + @Override + protected MultiValueMap initCookies() { + MultiValueMap httpCookies = new LinkedMultiValueMap<>(); + Cookie[] cookies = this.request.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + String name = cookie.getName(); + HttpCookie httpCookie = new HttpCookie(name, cookie.getValue()); + httpCookies.add(name, httpCookie); + } + } + return httpCookies; + } + + @Override + public Flux getBody() { + try { + RequestBodyPublisher bodyPublisher = this.bodyPublisher; + if (bodyPublisher == null) { + synchronized (this.bodyPublisherMonitor) { + bodyPublisher = this.bodyPublisher; + if (bodyPublisher == null) { + this.bodyPublisher = bodyPublisher = createBodyPublisher(); + } + } + } + return Flux.from(bodyPublisher); + } + catch (IOException ex) { + return Flux.error(ex); + } + } + + private RequestBodyPublisher createBodyPublisher() throws IOException { + RequestBodyPublisher bodyPublisher = + new RequestBodyPublisher(request.getInputStream(), this.dataBufferFactory, + this.bufferSize); + bodyPublisher.registerListener(); + return bodyPublisher; + } + + private static class RequestBodyPublisher extends AbstractRequestBodyPublisher { + + private final RequestBodyPublisher.RequestBodyReadListener readListener = + new RequestBodyPublisher.RequestBodyReadListener(); + + private final ServletInputStream inputStream; + + private final DataBufferFactory dataBufferFactory; + + private final byte[] buffer; + + public RequestBodyPublisher(ServletInputStream inputStream, + DataBufferFactory dataBufferFactory, int bufferSize) { + this.inputStream = inputStream; + this.dataBufferFactory = dataBufferFactory; + this.buffer = new byte[bufferSize]; + } + + public void registerListener() throws IOException { + this.inputStream.setReadListener(this.readListener); + } + + @Override + protected void checkOnDataAvailable() { + if (!this.inputStream.isFinished() && this.inputStream.isReady()) { + onDataAvailable(); + } + } + + @Override + protected DataBuffer read() throws IOException { + if (this.inputStream.isReady()) { + int read = this.inputStream.read(this.buffer); + if (logger.isTraceEnabled()) { + logger.trace("read:" + read); + } + + if (read > 0) { + DataBuffer dataBuffer = this.dataBufferFactory.allocateBuffer(read); + dataBuffer.write(this.buffer, 0, read); + return dataBuffer; + } + } + return null; + } + + private class RequestBodyReadListener implements ReadListener { + + @Override + public void onDataAvailable() throws IOException { + RequestBodyPublisher.this.onDataAvailable(); + } + + @Override + public void onAllDataRead() throws IOException { + RequestBodyPublisher.this.onAllDataRead(); + } + + @Override + public void onError(Throwable throwable) { + RequestBodyPublisher.this.onError(throwable); + + } + } + } +} diff --git a/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ServletServerHttpResponse.java b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ServletServerHttpResponse.java new file mode 100644 index 0000000000..1535eccdbd --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ServletServerHttpResponse.java @@ -0,0 +1,244 @@ +/* + * Copyright 2002-2016 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.reactive; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.Charset; +import java.util.List; +import java.util.Map; +import javax.servlet.ServletOutputStream; +import javax.servlet.WriteListener; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletResponse; + +import org.reactivestreams.Publisher; +import reactor.core.publisher.Mono; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseCookie; +import org.springframework.util.Assert; + +/** + * Adapt {@link ServerHttpResponse} to the Servlet {@link HttpServletResponse}. + * @author Rossen Stoyanchev + */ +public class ServletServerHttpResponse extends AbstractServerHttpResponse { + + private final Object bodyProcessorMonitor = new Object(); + + private volatile ResponseBodyProcessor bodyProcessor; + + private final HttpServletResponse response; + + private final int bufferSize; + + public ServletServerHttpResponse(HttpServletResponse response, + DataBufferFactory dataBufferFactory, int bufferSize) throws IOException { + super(dataBufferFactory); + Assert.notNull(response, "'response' must not be null"); + Assert.notNull(dataBufferFactory, "'dataBufferFactory' must not be null"); + Assert.isTrue(bufferSize > 0); + + this.response = response; + this.bufferSize = bufferSize; + } + + public HttpServletResponse getServletResponse() { + return this.response; + } + + @Override + protected void writeStatusCode() { + HttpStatus statusCode = this.getStatusCode(); + if (statusCode != null) { + getServletResponse().setStatus(statusCode.value()); + } + } + + @Override + protected Mono writeWithInternal(Publisher publisher) { + Assert.state(this.bodyProcessor == null, + "Response body publisher is already provided"); + try { + synchronized (this.bodyProcessorMonitor) { + if (this.bodyProcessor == null) { + this.bodyProcessor = createBodyProcessor(); + } + else { + throw new IllegalStateException( + "Response body publisher is already provided"); + } + } + return Mono.from(subscriber -> { + publisher.subscribe(this.bodyProcessor); + this.bodyProcessor.subscribe(subscriber); + }); + } + catch (IOException ex) { + return Mono.error(ex); + } + } + + private ResponseBodyProcessor createBodyProcessor() throws IOException { + ResponseBodyProcessor bodyProcessor = + new ResponseBodyProcessor(this.response.getOutputStream(), + this.bufferSize); + bodyProcessor.registerListener(); + return bodyProcessor; + } + + @Override + protected void writeHeaders() { + for (Map.Entry> entry : getHeaders().entrySet()) { + String headerName = entry.getKey(); + for (String headerValue : entry.getValue()) { + this.response.addHeader(headerName, headerValue); + } + } + MediaType contentType = getHeaders().getContentType(); + if (this.response.getContentType() == null && contentType != null) { + this.response.setContentType(contentType.toString()); + } + Charset charset = (contentType != null ? contentType.getCharset() : null); + if (this.response.getCharacterEncoding() == null && charset != null) { + this.response.setCharacterEncoding(charset.name()); + } + } + + @Override + protected void writeCookies() { + for (String name : getCookies().keySet()) { + for (ResponseCookie httpCookie : getCookies().get(name)) { + Cookie cookie = new Cookie(name, httpCookie.getValue()); + if (!httpCookie.getMaxAge().isNegative()) { + cookie.setMaxAge((int) httpCookie.getMaxAge().getSeconds()); + } + httpCookie.getDomain().ifPresent(cookie::setDomain); + httpCookie.getPath().ifPresent(cookie::setPath); + cookie.setSecure(httpCookie.isSecure()); + cookie.setHttpOnly(httpCookie.isHttpOnly()); + this.response.addCookie(cookie); + } + } + } + + private static class ResponseBodyProcessor extends AbstractResponseBodyProcessor { + + private final ResponseBodyWriteListener writeListener = + new ResponseBodyWriteListener(); + + private final ServletOutputStream outputStream; + + private final int bufferSize; + + private volatile boolean flushOnNext; + + public ResponseBodyProcessor(ServletOutputStream outputStream, int bufferSize) { + this.outputStream = outputStream; + this.bufferSize = bufferSize; + } + + public void registerListener() throws IOException { + this.outputStream.setWriteListener(this.writeListener); + } + + @Override + protected boolean isWritePossible() { + return this.outputStream.isReady(); + } + + @Override + protected boolean write(DataBuffer dataBuffer) throws IOException { + if (this.flushOnNext) { + flush(); + } + + boolean ready = this.outputStream.isReady(); + + if (this.logger.isTraceEnabled()) { + this.logger.trace("write: " + dataBuffer + " ready: " + ready); + } + + if (ready) { + int total = dataBuffer.readableByteCount(); + int written = writeDataBuffer(dataBuffer); + + if (this.logger.isTraceEnabled()) { + this.logger.trace("written: " + written + " total: " + total); + } + return written == total; + } + else { + return false; + } + } + + @Override + protected void flush() throws IOException { + if (this.outputStream.isReady()) { + if (logger.isTraceEnabled()) { + logger.trace("flush"); + } + try { + this.outputStream.flush(); + this.flushOnNext = false; + return; + } + catch (IOException ignored) { + } + } + this.flushOnNext = true; + + } + + private int writeDataBuffer(DataBuffer dataBuffer) throws IOException { + InputStream input = dataBuffer.asInputStream(); + + int bytesWritten = 0; + byte[] buffer = new byte[this.bufferSize]; + int bytesRead = -1; + + while (this.outputStream.isReady() && + (bytesRead = input.read(buffer)) != -1) { + this.outputStream.write(buffer, 0, bytesRead); + bytesWritten += bytesRead; + } + + return bytesWritten; + } + + private class ResponseBodyWriteListener implements WriteListener { + + @Override + public void onWritePossible() throws IOException { + ResponseBodyProcessor.this.onWritePossible(); + } + + @Override + public void onError(Throwable ex) { + // Error on writing to the HTTP stream, so any further writes will probably + // fail. Let's log instead of calling {@link #writeError}. + ResponseBodyProcessor.this.logger + .error("ResponseBodyWriteListener error", ex); + } + } + } +} \ No newline at end of file diff --git a/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/UndertowHttpHandlerAdapter.java b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/UndertowHttpHandlerAdapter.java new file mode 100644 index 0000000000..7380725e55 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/UndertowHttpHandlerAdapter.java @@ -0,0 +1,87 @@ +/* + * Copyright 2002-2016 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.reactive; + +import io.undertow.server.HttpServerExchange; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; + +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.util.Assert; + +/** + * @author Marek Hawrylczak + * @author Rossen Stoyanchev + * @author Arjen Poutsma + */ +public class UndertowHttpHandlerAdapter implements io.undertow.server.HttpHandler { + + private static Log logger = LogFactory.getLog(UndertowHttpHandlerAdapter.class); + + + private final HttpHandler delegate; + + private final DataBufferFactory dataBufferFactory; + + public UndertowHttpHandlerAdapter(HttpHandler delegate, + DataBufferFactory dataBufferFactory) { + Assert.notNull(delegate, "'delegate' is required"); + Assert.notNull(dataBufferFactory, "'dataBufferFactory' must not be null"); + this.delegate = delegate; + this.dataBufferFactory = dataBufferFactory; + } + + @Override + public void handleRequest(HttpServerExchange exchange) throws Exception { + + ServerHttpRequest request = + new UndertowServerHttpRequest(exchange, this.dataBufferFactory); + + ServerHttpResponse response = + new UndertowServerHttpResponse(exchange, this.dataBufferFactory); + + this.delegate.handle(request, response).subscribe(new Subscriber() { + + @Override + public void onSubscribe(Subscription subscription) { + subscription.request(Long.MAX_VALUE); + } + + @Override + public void onNext(Void aVoid) { + // no op + } + + @Override + public void onError(Throwable ex) { + logger.error("Error from request handling. Completing the request.", ex); + if (!exchange.isResponseStarted() && exchange.getStatusCode() <= 500) { + exchange.setStatusCode(500); + } + exchange.endExchange(); + } + + @Override + public void onComplete() { + exchange.endExchange(); + } + }); + } + +} \ No newline at end of file diff --git a/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/UndertowServerHttpRequest.java b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/UndertowServerHttpRequest.java new file mode 100644 index 0000000000..1649a25145 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/UndertowServerHttpRequest.java @@ -0,0 +1,170 @@ +/* + * Copyright 2002-2016 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.reactive; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.ByteBuffer; + +import io.undertow.connector.PooledByteBuffer; +import io.undertow.server.HttpServerExchange; +import io.undertow.server.handlers.Cookie; +import io.undertow.util.HeaderValues; +import org.xnio.ChannelListener; +import org.xnio.channels.StreamSourceChannel; +import reactor.core.publisher.Flux; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.http.HttpCookie; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.util.Assert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +/** + * Adapt {@link ServerHttpRequest} to the Underow {@link HttpServerExchange}. + * + * @author Marek Hawrylczak + * @author Rossen Stoyanchev + */ +public class UndertowServerHttpRequest extends AbstractServerHttpRequest { + + private final HttpServerExchange exchange; + + private final RequestBodyPublisher body; + + public UndertowServerHttpRequest(HttpServerExchange exchange, + DataBufferFactory dataBufferFactory) { + Assert.notNull(exchange, "'exchange' is required."); + this.exchange = exchange; + this.body = new RequestBodyPublisher(exchange, dataBufferFactory); + this.body.registerListener(); + } + + + public HttpServerExchange getUndertowExchange() { + return this.exchange; + } + + @Override + public HttpMethod getMethod() { + return HttpMethod.valueOf(this.getUndertowExchange().getRequestMethod().toString()); + } + + @Override + protected URI initUri() throws URISyntaxException { + return new URI(this.exchange.getRequestScheme(), null, + this.exchange.getHostName(), this.exchange.getHostPort(), + this.exchange.getRequestURI(), this.exchange.getQueryString(), null); + } + + @Override + protected HttpHeaders initHeaders() { + HttpHeaders headers = new HttpHeaders(); + for (HeaderValues values : this.getUndertowExchange().getRequestHeaders()) { + headers.put(values.getHeaderName().toString(), values); + } + return headers; + } + + @Override + protected MultiValueMap initCookies() { + MultiValueMap cookies = new LinkedMultiValueMap<>(); + for (String name : this.exchange.getRequestCookies().keySet()) { + Cookie cookie = this.exchange.getRequestCookies().get(name); + HttpCookie httpCookie = new HttpCookie(name, cookie.getValue()); + cookies.add(name, httpCookie); + } + return cookies; + } + + @Override + public Flux getBody() { + return Flux.from(this.body); + } + + private static class RequestBodyPublisher extends AbstractRequestBodyPublisher { + + private final ChannelListener readListener = + new ReadListener(); + + private final ChannelListener closeListener = + new CloseListener(); + + private final StreamSourceChannel requestChannel; + + private final DataBufferFactory dataBufferFactory; + + private final PooledByteBuffer pooledByteBuffer; + + public RequestBodyPublisher(HttpServerExchange exchange, + DataBufferFactory dataBufferFactory) { + this.requestChannel = exchange.getRequestChannel(); + this.pooledByteBuffer = + exchange.getConnection().getByteBufferPool().allocate(); + this.dataBufferFactory = dataBufferFactory; + } + + private void registerListener() { + this.requestChannel.getReadSetter().set(this.readListener); + this.requestChannel.getCloseSetter().set(this.closeListener); + this.requestChannel.resumeReads(); + } + + @Override + protected void checkOnDataAvailable() { + onDataAvailable(); + } + + @Override + protected DataBuffer read() throws IOException { + ByteBuffer byteBuffer = this.pooledByteBuffer.getBuffer(); + int read = this.requestChannel.read(byteBuffer); + if (logger.isTraceEnabled()) { + logger.trace("read:" + read); + } + + if (read > 0) { + byteBuffer.flip(); + return this.dataBufferFactory.wrap(byteBuffer); + } + else if (read == -1) { + onAllDataRead(); + } + return null; + } + + private class ReadListener implements ChannelListener { + + @Override + public void handleEvent(StreamSourceChannel channel) { + onDataAvailable(); + } + } + + private class CloseListener implements ChannelListener { + + @Override + public void handleEvent(StreamSourceChannel channel) { + onAllDataRead(); + } + } + } +} diff --git a/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/UndertowServerHttpResponse.java b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/UndertowServerHttpResponse.java new file mode 100644 index 0000000000..8d04fc99bb --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/UndertowServerHttpResponse.java @@ -0,0 +1,231 @@ +/* + * Copyright 2002-2016 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.reactive; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.util.List; +import java.util.Map; + +import io.undertow.server.HttpServerExchange; +import io.undertow.server.handlers.Cookie; +import io.undertow.server.handlers.CookieImpl; +import io.undertow.util.HttpString; +import org.reactivestreams.Publisher; +import org.xnio.ChannelListener; +import org.xnio.channels.StreamSinkChannel; +import reactor.core.publisher.Mono; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ZeroCopyHttpOutputMessage; +import org.springframework.util.Assert; + +/** + * Adapt {@link ServerHttpResponse} to the Undertow {@link HttpServerExchange}. + * @author Marek Hawrylczak + * @author Rossen Stoyanchev + * @author Arjen Poutsma + */ +public class UndertowServerHttpResponse extends AbstractServerHttpResponse + implements ZeroCopyHttpOutputMessage { + + private final Object bodyProcessorMonitor = new Object(); + + private volatile ResponseBodyProcessor bodyProcessor; + + private final HttpServerExchange exchange; + + public UndertowServerHttpResponse(HttpServerExchange exchange, + DataBufferFactory dataBufferFactory) { + super(dataBufferFactory); + Assert.notNull(exchange, "'exchange' is required."); + this.exchange = exchange; + } + + public HttpServerExchange getUndertowExchange() { + return this.exchange; + } + + @Override + protected void writeStatusCode() { + HttpStatus statusCode = this.getStatusCode(); + if (statusCode != null) { + getUndertowExchange().setStatusCode(statusCode.value()); + } + } + + @Override + protected Mono writeWithInternal(Publisher publisher) { + Assert.state(this.bodyProcessor == null, + "Response body publisher is already provided"); + try { + synchronized (this.bodyProcessorMonitor) { + if (this.bodyProcessor == null) { + this.bodyProcessor = createBodyProcessor(); + } + else { + throw new IllegalStateException( + "Response body publisher is already provided"); + } + } + return Mono.from(subscriber -> { + publisher.subscribe(this.bodyProcessor); + this.bodyProcessor.subscribe(subscriber); + }); + } + catch (IOException ex) { + return Mono.error(ex); + } + } + + private ResponseBodyProcessor createBodyProcessor() throws IOException { + ResponseBodyProcessor bodyProcessor = new ResponseBodyProcessor(this.exchange); + bodyProcessor.registerListener(); + return bodyProcessor; + } + + + @Override + public Mono writeWith(File file, long position, long count) { + writeHeaders(); + writeCookies(); + + try { + StreamSinkChannel responseChannel = + getUndertowExchange().getResponseChannel(); + FileChannel in = new FileInputStream(file).getChannel(); + long result = responseChannel.transferFrom(in, position, count); + if (result < count) { + return Mono.error(new IOException( + "Could only write " + result + " out of " + count + " bytes")); + } + else { + return Mono.empty(); + } + } + catch (IOException ex) { + return Mono.error(ex); + } + } + + @Override + protected void writeHeaders() { + for (Map.Entry> entry : getHeaders().entrySet()) { + HttpString headerName = HttpString.tryFromString(entry.getKey()); + this.exchange.getResponseHeaders().addAll(headerName, entry.getValue()); + } + } + + @Override + protected void writeCookies() { + for (String name : getCookies().keySet()) { + for (ResponseCookie httpCookie : getCookies().get(name)) { + Cookie cookie = new CookieImpl(name, httpCookie.getValue()); + if (!httpCookie.getMaxAge().isNegative()) { + cookie.setMaxAge((int) httpCookie.getMaxAge().getSeconds()); + } + httpCookie.getDomain().ifPresent(cookie::setDomain); + httpCookie.getPath().ifPresent(cookie::setPath); + cookie.setSecure(httpCookie.isSecure()); + cookie.setHttpOnly(httpCookie.isHttpOnly()); + this.exchange.getResponseCookies().putIfAbsent(name, cookie); + } + } + } + + private static class ResponseBodyProcessor extends AbstractResponseBodyProcessor { + + private final ChannelListener listener = new WriteListener(); + + private final StreamSinkChannel responseChannel; + + private volatile ByteBuffer byteBuffer; + + public ResponseBodyProcessor(HttpServerExchange exchange) { + this.responseChannel = exchange.getResponseChannel(); + } + + public void registerListener() { + this.responseChannel.getWriteSetter().set(this.listener); + this.responseChannel.resumeWrites(); + } + + @Override + protected void flush() throws IOException { + if (logger.isTraceEnabled()) { + logger.trace("flush"); + } + this.responseChannel.flush(); + } + + @Override + protected boolean write(DataBuffer dataBuffer) throws IOException { + if (this.byteBuffer == null) { + return false; + } + if (logger.isTraceEnabled()) { + logger.trace("write: " + dataBuffer); + } + int total = this.byteBuffer.remaining(); + int written = writeByteBuffer(this.byteBuffer); + + if (logger.isTraceEnabled()) { + logger.trace("written: " + written + " total: " + total); + } + return written == total; + } + + private int writeByteBuffer(ByteBuffer byteBuffer) throws IOException { + int written; + int totalWritten = 0; + do { + written = this.responseChannel.write(byteBuffer); + totalWritten += written; + } + while (byteBuffer.hasRemaining() && written > 0); + return totalWritten; + } + + @Override + protected void receiveBuffer(DataBuffer dataBuffer) { + super.receiveBuffer(dataBuffer); + this.byteBuffer = dataBuffer.asByteBuffer(); + } + + @Override + protected void releaseBuffer() { + super.releaseBuffer(); + this.byteBuffer = null; + } + + private class WriteListener implements ChannelListener { + + @Override + public void handleEvent(StreamSinkChannel channel) { + onWritePossible(); + } + + } + + } +} diff --git a/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/package-info.java b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/package-info.java new file mode 100644 index 0000000000..9568060c17 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/package-info.java @@ -0,0 +1,4 @@ +/** + * Core package of the reactive server-side HTTP support. + */ +package org.springframework.http.server.reactive; diff --git a/spring-web-reactive/src/main/java/org/springframework/http/support/MediaTypeUtils.java b/spring-web-reactive/src/main/java/org/springframework/http/support/MediaTypeUtils.java new file mode 100644 index 0000000000..68d1612e8e --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/http/support/MediaTypeUtils.java @@ -0,0 +1,47 @@ +/* + * Copyright 2002-2016 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.support; + +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.http.MediaType; +import org.springframework.util.MimeType; + +/** + * @author Arjen Poutsma + */ +public abstract class MediaTypeUtils { + + /** + * TODO: move to MediaType static method + */ + public static List toMediaTypes(List mimeTypes) { + return mimeTypes.stream().map(MediaTypeUtils::toMediaType) + .collect(Collectors.toList()); + } + + /** + * TODO: move to MediaType constructor + */ + public static MediaType toMediaType(MimeType mimeType) { + return new MediaType(mimeType.getType(), mimeType.getSubtype(), + mimeType.getParameters()); + } + + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/util/MimeTypeUtils2.java b/spring-web-reactive/src/main/java/org/springframework/util/MimeTypeUtils2.java new file mode 100644 index 0000000000..6ef2f9fb6c --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/util/MimeTypeUtils2.java @@ -0,0 +1,87 @@ +/* + * Copyright 2002-2016 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.util; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Optional; +import javax.activation.FileTypeMap; +import javax.activation.MimetypesFileTypeMap; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; + +/** + * TODO: merge into {@link MimeTypeUtils}, and use wherever we still have a runtime check + * to see if JAF is available (i.e. jafPresent). Since JAF has been included in the JDK + * since 1.6, we don't + * need that check anymore. (i.e. {@link org.springframework.http.converter.ResourceHttpMessageConverter} + * @author Arjen Poutsma + */ +public abstract class MimeTypeUtils2 extends MimeTypeUtils { + + private static final FileTypeMap fileTypeMap; + + static { + fileTypeMap = loadFileTypeMapFromContextSupportModule(); + } + + private static FileTypeMap loadFileTypeMapFromContextSupportModule() { + // See if we can find the extended mime.types from the context-support module... + Resource mappingLocation = + new ClassPathResource("org/springframework/mail/javamail/mime.types"); + if (mappingLocation.exists()) { + InputStream inputStream = null; + try { + inputStream = mappingLocation.getInputStream(); + return new MimetypesFileTypeMap(inputStream); + } + catch (IOException ex) { + // ignore + } + finally { + if (inputStream != null) { + try { + inputStream.close(); + } + catch (IOException ex) { + // ignore + } + } + } + } + return FileTypeMap.getDefaultFileTypeMap(); + } + + /** + * Returns the {@code MimeType} of the given file name, using the Java Activation + * Framework. + * @param filename the filename whose mime type is to be found + * @return the mime type, if any + */ + public static Optional getMimeType(String filename) { + if (filename != null) { + String mimeType = fileTypeMap.getContentType(filename); + if (StringUtils.hasText(mimeType)) { + return Optional.of(parseMimeType(mimeType)); + } + } + return Optional.empty(); + } + + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/util/xml/AbstractXMLEventReader.java b/spring-web-reactive/src/main/java/org/springframework/util/xml/AbstractXMLEventReader.java new file mode 100644 index 0000000000..5342d932ff --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/util/xml/AbstractXMLEventReader.java @@ -0,0 +1,139 @@ +/* + * Copyright 2002-2016 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.util.xml; + +import java.util.NoSuchElementException; +import javax.xml.stream.XMLEventReader; +import javax.xml.stream.XMLStreamConstants; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.events.Characters; +import javax.xml.stream.events.XMLEvent; + +import org.springframework.util.ClassUtils; + +/** + * Abstract base class for {@code XMLEventReader}s. + * @author Arjen Poutsma + */ +abstract class AbstractXMLEventReader implements XMLEventReader { + + private boolean closed; + + @Override + public Object next() { + try { + return nextEvent(); + } + catch (XMLStreamException ex) { + throw new NoSuchElementException(); + } + } + + @Override + public void remove() { + throw new UnsupportedOperationException( + "remove not supported on " + ClassUtils.getShortName(getClass())); + } + + @Override + public String getElementText() throws XMLStreamException { + checkIfClosed(); + if (!peek().isStartElement()) { + throw new XMLStreamException("Not at START_ELEMENT"); + } + + StringBuilder builder = new StringBuilder(); + while (true) { + XMLEvent event = nextEvent(); + if (event.isEndElement()) { + break; + } + else if (!event.isCharacters()) { + throw new XMLStreamException( + "Unexpected event [" + event + "] in getElementText()"); + } + Characters characters = event.asCharacters(); + if (!characters.isIgnorableWhiteSpace()) { + builder.append(event.asCharacters().getData()); + } + } + return builder.toString(); + } + + @Override + public XMLEvent nextTag() throws XMLStreamException { + checkIfClosed(); + while (true) { + XMLEvent event = nextEvent(); + switch (event.getEventType()) { + case XMLStreamConstants.START_ELEMENT: + case XMLStreamConstants.END_ELEMENT: + return event; + case XMLStreamConstants.END_DOCUMENT: + return null; + case XMLStreamConstants.SPACE: + case XMLStreamConstants.COMMENT: + case XMLStreamConstants.PROCESSING_INSTRUCTION: + continue; + case XMLStreamConstants.CDATA: + case XMLStreamConstants.CHARACTERS: + if (!event.asCharacters().isWhiteSpace()) { + throw new XMLStreamException( + "Non-ignorable whitespace CDATA or CHARACTERS event in nextTag()"); + } + break; + default: + throw new XMLStreamException("Received event [" + event + + "], instead of START_ELEMENT or END_ELEMENT."); + } + } + } + + /** + * Throws an {@code IllegalArgumentException} when called. + * @throws IllegalArgumentException when called. + */ + @Override + public Object getProperty(String name) throws IllegalArgumentException { + throw new IllegalArgumentException("Property not supported: [" + name + "]"); + } + + /** + * Returns {@code true} if closed; {@code false} otherwise. + * @see #close() + */ + protected boolean isClosed() { + return closed; + } + + /** + * Checks if the reader is closed, and throws a {@code XMLStreamException} if so. + * @throws XMLStreamException if the reader is closed + * @see #close() + * @see #isClosed() + */ + protected void checkIfClosed() throws XMLStreamException { + if (isClosed()) { + throw new XMLStreamException("XMLEventReader has been closed"); + } + } + + @Override + public void close() { + closed = true; + } +} diff --git a/spring-web-reactive/src/main/java/org/springframework/util/xml/ListBasedXMLEventReader.java b/spring-web-reactive/src/main/java/org/springframework/util/xml/ListBasedXMLEventReader.java new file mode 100644 index 0000000000..a8d2345cfa --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/util/xml/ListBasedXMLEventReader.java @@ -0,0 +1,72 @@ +/* + * Copyright 2002-2016 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.util.xml; + +import java.util.Collections; +import java.util.List; +import java.util.NoSuchElementException; +import javax.xml.stream.events.XMLEvent; + +import org.springframework.util.Assert; + +/** + * Implementation of {@code XMLEventReader} based on a list of {@link XMLEvent}s. + * + * @author Arjen Poutsma + */ +class ListBasedXMLEventReader extends AbstractXMLEventReader { + + private final List events; + + private int cursor = 0; + + public ListBasedXMLEventReader(List events) { + Assert.notNull(events, "'events' must not be null"); + this.events = Collections.unmodifiableList(events); + } + + @Override + public boolean hasNext() { + return cursor != events.size(); + } + + @Override + public XMLEvent nextEvent() { + if (cursor < events.size()) { + return events.get(cursor++); + } + else { + throw new NoSuchElementException(); + } + } + + @Override + public XMLEvent peek() { + if (cursor < events.size()) { + return events.get(cursor); + } + else { + return null; + } + } + + @Override + public void close() { + super.close(); + this.events.clear(); + } +} diff --git a/spring-web-reactive/src/main/java/org/springframework/util/xml/StaxUtils2.java b/spring-web-reactive/src/main/java/org/springframework/util/xml/StaxUtils2.java new file mode 100644 index 0000000000..7d4c2c3f8f --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/util/xml/StaxUtils2.java @@ -0,0 +1,38 @@ +/* + * Copyright 2002-2016 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.util.xml; + +import java.util.List; +import javax.xml.stream.XMLEventReader; +import javax.xml.stream.events.XMLEvent; + +/** + * TODO: to be merged with {@link StaxUtils}. + * @author Arjen Poutsma + */ +public abstract class StaxUtils2 { + + /** + * Create a {@link XMLEventReader} from the given list of {@link XMLEvent}. + * @param events the list of {@link XMLEvent}s. + * @return an {@code XMLEventReader} that reads from the given events + */ + public static XMLEventReader createXMLEventReader(List events) { + return new ListBasedXMLEventReader(events); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/ClientWebRequest.java b/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/ClientWebRequest.java new file mode 100644 index 0000000000..c16950f87c --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/ClientWebRequest.java @@ -0,0 +1,96 @@ +/* + * Copyright 2002-2016 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.client.reactive; + +import java.net.URI; + +import org.reactivestreams.Publisher; + +import org.springframework.core.ResolvableType; +import org.springframework.http.HttpCookie; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.util.MultiValueMap; + +/** + * Holds all the application information required to build an actual HTTP client request. + *

The request body is materialized by a {@code Publisher} of Objects and their type + * by a {@code ResolvableType} instance; it should be later converted to a + * {@code Publisher} to be written to the actual HTTP client request. + * + * @author Brian Clozel + */ +public class ClientWebRequest { + + protected final HttpMethod httpMethod; + + protected final URI url; + + protected HttpHeaders httpHeaders; + + private MultiValueMap cookies; + + protected Publisher body; + + protected ResolvableType elementType; + + + public ClientWebRequest(HttpMethod httpMethod, URI url) { + this.httpMethod = httpMethod; + this.url = url; + } + + public HttpMethod getMethod() { + return httpMethod; + } + + public URI getUrl() { + return url; + } + + public HttpHeaders getHttpHeaders() { + return httpHeaders; + } + + public void setHttpHeaders(HttpHeaders httpHeaders) { + this.httpHeaders = httpHeaders; + } + + public MultiValueMap getCookies() { + return cookies; + } + + public void setCookies(MultiValueMap cookies) { + this.cookies = cookies; + } + + public Publisher getBody() { + return body; + } + + public void setBody(Publisher body) { + this.body = body; + } + + public ResolvableType getElementType() { + return elementType; + } + + public void setElementType(ResolvableType elementType) { + this.elementType = elementType; + } +} \ No newline at end of file diff --git a/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/ClientWebRequestBuilder.java b/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/ClientWebRequestBuilder.java new file mode 100644 index 0000000000..1baf43a2e9 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/ClientWebRequestBuilder.java @@ -0,0 +1,28 @@ +/* + * Copyright 2002-2016 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.client.reactive; + +/** + * Build {@link ClientWebRequest}s + * + * @author Brian Clozel + */ +public interface ClientWebRequestBuilder { + + ClientWebRequest build(); + +} \ No newline at end of file diff --git a/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/ClientWebRequestBuilders.java b/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/ClientWebRequestBuilders.java new file mode 100644 index 0000000000..277dd57039 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/ClientWebRequestBuilders.java @@ -0,0 +1,111 @@ +/* + * Copyright 2002-2016 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.client.reactive; + + +import org.springframework.http.HttpMethod; + +/** + * Static factory methods for {@link DefaultClientWebRequestBuilder ClientWebRequestBuilders} + * + * @author Brian Clozel + */ +public abstract class ClientWebRequestBuilders { + + /** + * Create a {@link DefaultClientWebRequestBuilder} for a GET request. + * + * @param urlTemplate a URL template; the resulting URL will be encoded + * @param urlVariables zero or more URL variables + */ + public static DefaultClientWebRequestBuilder get(String urlTemplate, Object... urlVariables) { + return new DefaultClientWebRequestBuilder(HttpMethod.GET, urlTemplate, urlVariables); + } + + /** + * Create a {@link DefaultClientWebRequestBuilder} for a POST request. + * + * @param urlTemplate a URL template; the resulting URL will be encoded + * @param urlVariables zero or more URL variables + */ + public static DefaultClientWebRequestBuilder post(String urlTemplate, Object... urlVariables) { + return new DefaultClientWebRequestBuilder(HttpMethod.POST, urlTemplate, urlVariables); + } + + + /** + * Create a {@link DefaultClientWebRequestBuilder} for a PUT request. + * + * @param urlTemplate a URL template; the resulting URL will be encoded + * @param urlVariables zero or more URL variables + */ + public static DefaultClientWebRequestBuilder put(String urlTemplate, Object... urlVariables) { + return new DefaultClientWebRequestBuilder(HttpMethod.PUT, urlTemplate, urlVariables); + } + + /** + * Create a {@link DefaultClientWebRequestBuilder} for a PATCH request. + * + * @param urlTemplate a URL template; the resulting URL will be encoded + * @param urlVariables zero or more URL variables + */ + public static DefaultClientWebRequestBuilder patch(String urlTemplate, Object... urlVariables) { + return new DefaultClientWebRequestBuilder(HttpMethod.PATCH, urlTemplate, urlVariables); + } + + /** + * Create a {@link DefaultClientWebRequestBuilder} for a DELETE request. + * + * @param urlTemplate a URL template; the resulting URL will be encoded + * @param urlVariables zero or more URL variables + */ + public static DefaultClientWebRequestBuilder delete(String urlTemplate, Object... urlVariables) { + return new DefaultClientWebRequestBuilder(HttpMethod.DELETE, urlTemplate, urlVariables); + } + + /** + * Create a {@link DefaultClientWebRequestBuilder} for an OPTIONS request. + * + * @param urlTemplate a URL template; the resulting URL will be encoded + * @param urlVariables zero or more URL variables + */ + public static DefaultClientWebRequestBuilder options(String urlTemplate, Object... urlVariables) { + return new DefaultClientWebRequestBuilder(HttpMethod.OPTIONS, urlTemplate, urlVariables); + } + + /** + * Create a {@link DefaultClientWebRequestBuilder} for a HEAD request. + * + * @param urlTemplate a URL template; the resulting URL will be encoded + * @param urlVariables zero or more URL variables + */ + public static DefaultClientWebRequestBuilder head(String urlTemplate, Object... urlVariables) { + return new DefaultClientWebRequestBuilder(HttpMethod.HEAD, urlTemplate, urlVariables); + } + + /** + * Create a {@link DefaultClientWebRequestBuilder} for a request with the given HTTP method. + * + * @param httpMethod the HTTP method + * @param urlTemplate a URL template; the resulting URL will be encoded + * @param urlVariables zero or more URL variables + */ + public static DefaultClientWebRequestBuilder request(HttpMethod httpMethod, String urlTemplate, Object... urlVariables) { + return new DefaultClientWebRequestBuilder(httpMethod, urlTemplate, urlVariables); + } + +} \ No newline at end of file diff --git a/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/ClientWebRequestPostProcessor.java b/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/ClientWebRequestPostProcessor.java new file mode 100644 index 0000000000..40df349531 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/ClientWebRequestPostProcessor.java @@ -0,0 +1,36 @@ +/* + * Copyright 2002-2016 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.client.reactive; + +/** + * Allow post processing and/or wrapping the {@link ClientWebRequest} before + * it's sent to the origin server. + * + * @author Rob Winch + * @author Brian Clozel + * @see DefaultClientWebRequestBuilder#apply(ClientWebRequestPostProcessor) + */ +public interface ClientWebRequestPostProcessor { + + /** + * Implementations can modify and/or wrap the {@link ClientWebRequest} passed in + * and return it + * + * @param request the {@link ClientWebRequest} to be modified and/or wrapped. + */ + ClientWebRequest postProcess(ClientWebRequest request); +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/DefaultClientWebRequestBuilder.java b/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/DefaultClientWebRequestBuilder.java new file mode 100644 index 0000000000..4e399f65f1 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/DefaultClientWebRequestBuilder.java @@ -0,0 +1,196 @@ +/* + * Copyright 2002-2016 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.client.reactive; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import org.reactivestreams.Publisher; +import reactor.core.publisher.Mono; + +import org.springframework.core.ResolvableType; +import org.springframework.http.HttpCookie; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.client.reactive.ClientHttpRequest; +import org.springframework.util.Assert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.util.DefaultUriTemplateHandler; +import org.springframework.web.util.UriTemplateHandler; + +/** + * Builds a {@link ClientHttpRequest} using a {@link Publisher} + * as request body. + * + *

See static factory methods in {@link ClientWebRequestBuilders} + * + * @author Brian Clozel + * @see ClientWebRequestBuilders + */ +public class DefaultClientWebRequestBuilder implements ClientWebRequestBuilder { + + + private final UriTemplateHandler uriTemplateHandler = new DefaultUriTemplateHandler(); + + private HttpMethod httpMethod; + + private HttpHeaders httpHeaders; + + private URI url; + + private final MultiValueMap cookies = new LinkedMultiValueMap<>(); + + private Publisher body; + + private ResolvableType elementType; + + private List postProcessors = new ArrayList<>(); + + protected DefaultClientWebRequestBuilder() { + } + + public DefaultClientWebRequestBuilder(HttpMethod httpMethod, String urlTemplate, + Object... urlVariables) { + this.httpMethod = httpMethod; + this.httpHeaders = new HttpHeaders(); + this.url = this.uriTemplateHandler.expand(urlTemplate, urlVariables); + } + + public DefaultClientWebRequestBuilder(HttpMethod httpMethod, URI url) { + this.httpMethod = httpMethod; + this.httpHeaders = new HttpHeaders(); + this.url = url; + } + + /** + * Add an HTTP request header + */ + public DefaultClientWebRequestBuilder header(String name, String... values) { + Arrays.stream(values).forEach(value -> this.httpHeaders.add(name, value)); + return this; + } + + /** + * Add all provided HTTP request headers + */ + public DefaultClientWebRequestBuilder headers(HttpHeaders httpHeaders) { + this.httpHeaders = httpHeaders; + return this; + } + + /** + * Set the Content-Type request header to the given {@link MediaType} + */ + public DefaultClientWebRequestBuilder contentType(MediaType contentType) { + this.httpHeaders.setContentType(contentType); + return this; + } + + /** + * Set the Content-Type request header to the given media type + */ + public DefaultClientWebRequestBuilder contentType(String contentType) { + this.httpHeaders.setContentType(MediaType.parseMediaType(contentType)); + return this; + } + + /** + * Set the Accept request header to the given {@link MediaType}s + */ + public DefaultClientWebRequestBuilder accept(MediaType... mediaTypes) { + this.httpHeaders.setAccept(Arrays.asList(mediaTypes)); + return this; + } + + /** + * Set the Accept request header to the given media types + */ + public DefaultClientWebRequestBuilder accept(String... mediaTypes) { + this.httpHeaders.setAccept( + Arrays.stream(mediaTypes).map(type -> MediaType.parseMediaType(type)) + .collect(Collectors.toList())); + return this; + } + + /** + * Add a Cookie to the HTTP request + */ + public DefaultClientWebRequestBuilder cookie(String name, String value) { + return cookie(new HttpCookie(name, value)); + } + + /** + * Add a Cookie to the HTTP request + */ + public DefaultClientWebRequestBuilder cookie(HttpCookie cookie) { + this.cookies.add(cookie.getName(), cookie); + return this; + } + + /** + * Allows performing more complex operations with a strategy. For example, a + * {@link ClientWebRequestPostProcessor} implementation might accept the arguments of username + * and password and set an HTTP Basic authentication header. + * + * @param postProcessor the {@link ClientWebRequestPostProcessor} to use. Cannot be null. + * + * @return this instance for further modifications. + */ + public DefaultClientWebRequestBuilder apply(ClientWebRequestPostProcessor postProcessor) { + Assert.notNull(postProcessor, "`postProcessor` is required"); + this.postProcessors.add(postProcessor); + return this; + } + + /** + * Use the given object as the request body + */ + public DefaultClientWebRequestBuilder body(Object content) { + this.body = Mono.just(content); + this.elementType = ResolvableType.forInstance(content); + return this; + } + + /** + * Use the given {@link Publisher} as the request body and use its {@link ResolvableType} + * as type information for the element published by this reactive stream + */ + public DefaultClientWebRequestBuilder body(Publisher content, ResolvableType publisherType) { + this.body = content; + this.elementType = publisherType; + return this; + } + + @Override + public ClientWebRequest build() { + ClientWebRequest clientWebRequest = new ClientWebRequest(this.httpMethod, this.url); + clientWebRequest.setHttpHeaders(this.httpHeaders); + clientWebRequest.setCookies(this.cookies); + clientWebRequest.setBody(this.body); + clientWebRequest.setElementType(this.elementType); + for (ClientWebRequestPostProcessor postProcessor : this.postProcessors) { + clientWebRequest = postProcessor.postProcess(clientWebRequest); + } + return clientWebRequest; + } + +} \ No newline at end of file diff --git a/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/ResponseExtractor.java b/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/ResponseExtractor.java new file mode 100644 index 0000000000..3ed162c97d --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/ResponseExtractor.java @@ -0,0 +1,38 @@ +/* + * Copyright 2002-2016 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.client.reactive; + +import java.util.List; + +import reactor.core.publisher.Mono; + +import org.springframework.http.client.reactive.ClientHttpResponse; +import org.springframework.http.converter.reactive.HttpMessageConverter; + +/** + * A {@code ResponseExtractor} extracts the relevant part of a + * raw {@link org.springframework.http.client.reactive.ClientHttpResponse}, + * optionally decoding the response body and using a target composition API. + * + *

See static factory methods in {@link ResponseExtractors}. + * + * @author Brian Clozel + */ +public interface ResponseExtractor { + + T extract(Mono clientResponse, List> messageConverters); +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/ResponseExtractors.java b/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/ResponseExtractors.java new file mode 100644 index 0000000000..1e650ec91a --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/ResponseExtractors.java @@ -0,0 +1,163 @@ +/* + * Copyright 2002-2016 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.client.reactive; + +import java.util.List; +import java.util.Optional; + +import org.springframework.core.ResolvableType; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.reactive.ClientHttpResponse; +import org.springframework.http.converter.reactive.HttpMessageConverter; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Static factory methods for {@link ResponseExtractor} based on the {@link Flux} and + * {@link Mono} API. + * + * @author Brian Clozel + */ +public class ResponseExtractors { + + private static final Object EMPTY_BODY = new Object(); + + /** + * Extract the response body and decode it, returning it as a {@code Mono} + * @see ResolvableType#forClassWithGenerics(Class, Class[]) + */ + public static ResponseExtractor> body(ResolvableType bodyType) { + // noinspection unchecked + return (clientResponse, messageConverters) -> (Mono) clientResponse + .flatMap(resp -> decodeResponseBody(resp, bodyType, + messageConverters)) + .next(); + } + + /** + * Extract the response body and decode it, returning it as a {@code Mono} + */ + public static ResponseExtractor> body(Class sourceClass) { + ResolvableType bodyType = ResolvableType.forClass(sourceClass); + return body(bodyType); + } + + /** + * Extract the response body and decode it, returning it as a {@code Flux} + * @see ResolvableType#forClassWithGenerics(Class, Class[]) + */ + public static ResponseExtractor> bodyStream(ResolvableType bodyType) { + return (clientResponse, messageConverters) -> clientResponse + .flatMap(resp -> decodeResponseBody(resp, bodyType, messageConverters)); + } + + /** + * Extract the response body and decode it, returning it as a {@code Flux} + */ + public static ResponseExtractor> bodyStream(Class sourceClass) { + ResolvableType bodyType = ResolvableType.forClass(sourceClass); + return bodyStream(bodyType); + } + + /** + * Extract the full response body as a {@code ResponseEntity} with its body decoded as + * a single type {@code T} + * @see ResolvableType#forClassWithGenerics(Class, Class[]) + */ + public static ResponseExtractor>> response( + ResolvableType bodyType) { + return (clientResponse, messageConverters) -> clientResponse.then(response -> { + return Mono.when( + decodeResponseBody(response, bodyType, + messageConverters).next().defaultIfEmpty( + EMPTY_BODY), + Mono.just(response.getHeaders()), + Mono.just(response.getStatusCode())); + }).map(tuple -> { + Object body = (tuple.getT1() != EMPTY_BODY ? tuple.getT1() : null); + // noinspection unchecked + return new ResponseEntity<>((T) body, tuple.getT2(), tuple.getT3()); + }); + } + + /** + * Extract the full response body as a {@code ResponseEntity} with its body decoded as + * a single type {@code T} + */ + public static ResponseExtractor>> response( + Class bodyClass) { + ResolvableType bodyType = ResolvableType.forClass(bodyClass); + return response(bodyType); + } + + /** + * Extract the full response body as a {@code ResponseEntity} with its body decoded as + * a {@code Flux} + * @see ResolvableType#forClassWithGenerics(Class, Class[]) + */ + public static ResponseExtractor>>> responseStream( + ResolvableType type) { + return (clientResponse, messageConverters) -> clientResponse + .map(response -> new ResponseEntity<>( + decodeResponseBody(response, type, + messageConverters), + response.getHeaders(), response.getStatusCode())); + } + + /** + * Extract the full response body as a {@code ResponseEntity} with its body decoded as + * a {@code Flux} + */ + public static ResponseExtractor>>> responseStream( + Class sourceClass) { + ResolvableType resolvableType = ResolvableType.forClass(sourceClass); + return responseStream(resolvableType); + } + + /** + * Extract the response headers as an {@code HttpHeaders} instance + */ + public static ResponseExtractor> headers() { + return (clientResponse, messageConverters) -> clientResponse.map(resp -> resp.getHeaders()); + } + + protected static Flux decodeResponseBody(ClientHttpResponse response, + ResolvableType responseType, + List> messageConverters) { + + MediaType contentType = response.getHeaders().getContentType(); + Optional> converter = resolveConverter(messageConverters, + responseType, contentType); + if (!converter.isPresent()) { + return Flux.error(new IllegalStateException( + "Could not decode response body of type '" + contentType + + "' with target type '" + responseType.toString() + "'")); + } + // noinspection unchecked + return (Flux) converter.get().read(responseType, response); + } + + protected static Optional> resolveConverter( + List> messageConverters, ResolvableType type, + MediaType mediaType) { + return messageConverters.stream().filter(e -> e.canRead(type, mediaType)) + .findFirst(); + } +} \ No newline at end of file diff --git a/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/WebClient.java b/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/WebClient.java new file mode 100644 index 0000000000..94f4840c54 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/WebClient.java @@ -0,0 +1,229 @@ +/* + * Copyright 2002-2016 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.client.reactive; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.logging.Level; + +import org.reactivestreams.Publisher; + +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.Decoder; +import org.springframework.core.codec.Encoder; +import org.springframework.core.codec.ByteBufferDecoder; +import org.springframework.core.codec.ByteBufferEncoder; +import org.springframework.http.codec.json.JacksonJsonDecoder; +import org.springframework.http.codec.json.JacksonJsonEncoder; +import org.springframework.core.codec.StringDecoder; +import org.springframework.core.codec.StringEncoder; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.client.reactive.ClientHttpRequest; +import org.springframework.http.client.reactive.ClientHttpResponse; +import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.http.codec.xml.Jaxb2Decoder; +import org.springframework.http.codec.xml.Jaxb2Encoder; +import org.springframework.http.converter.reactive.CodecHttpMessageConverter; +import org.springframework.http.converter.reactive.HttpMessageConverter; +import org.springframework.http.converter.reactive.ResourceHttpMessageConverter; +import org.springframework.util.ClassUtils; + +import reactor.core.publisher.Mono; + +/** + * Reactive Web client supporting the HTTP/1.1 protocol + * + *

Here is a simple example of a GET request: + * + *

+ * // should be shared between HTTP calls
+ * WebClient client = new WebClient(new ReactorHttpClient());
+ *
+ * Mono<String> result = client
+ * 		.perform(ClientWebRequestBuilders.get("http://example.org/resource")
+ * 				.accept(MediaType.TEXT_PLAIN))
+ * 		.extract(ResponseExtractors.body(String.class));
+ * 
+ * + *

This Web client relies on + *

    + *
  • an {@link ClientHttpConnector} implementation that drives the underlying library (e.g. Reactor-Netty)
  • + *
  • a {@link ClientWebRequestBuilder} which creates a Web request with a builder API (see + * {@link ClientWebRequestBuilders})
  • + *
  • an {@link ResponseExtractor} which extracts the relevant part of the server + * response with the composition API of choice (see {@link ResponseExtractors}
  • + *
+ * + * @author Brian Clozel + * @see ClientWebRequestBuilders + * @see ResponseExtractors + */ +public final class WebClient { + + private static final ClassLoader classLoader = WebClient.class.getClassLoader(); + + private static final boolean jackson2Present = ClassUtils + .isPresent("com.fasterxml.jackson.databind.ObjectMapper", classLoader) + && ClassUtils.isPresent("com.fasterxml.jackson.core.JsonGenerator", + classLoader); + + private static final boolean jaxb2Present = ClassUtils + .isPresent("javax.xml.bind.Binder", classLoader); + + private ClientHttpConnector clientHttpConnector; + + private List> messageConverters; + + /** + * Create a {@code WebClient} instance, using the {@link ClientHttpConnector} + * implementation given as an argument to drive the underlying + * implementation. + * + * Register by default the following Encoders and Decoders: + *
    + *
  • {@link ByteBufferEncoder} / {@link ByteBufferDecoder}
  • + *
  • {@link StringEncoder} / {@link StringDecoder}
  • + *
  • {@link Jaxb2Encoder} / {@link Jaxb2Decoder}
  • + *
  • {@link JacksonJsonEncoder} / {@link JacksonJsonDecoder}
  • + *
+ * + * @param clientHttpConnector the {@code ClientHttpRequestFactory} to use + */ + public WebClient(ClientHttpConnector clientHttpConnector) { + this.clientHttpConnector = clientHttpConnector; + this.messageConverters = new ArrayList<>(); + addDefaultHttpMessageConverters(this.messageConverters); + } + + /** + * Adds default HTTP message converters + */ + protected final void addDefaultHttpMessageConverters( + List> converters) { + converters.add(converter(new ByteBufferEncoder(), new ByteBufferDecoder())); + converters.add(converter(new StringEncoder(), new StringDecoder())); + converters.add(new ResourceHttpMessageConverter()); + if (jaxb2Present) { + converters.add(converter(new Jaxb2Encoder(), new Jaxb2Decoder())); + } + if (jackson2Present) { + converters.add(converter(new JacksonJsonEncoder(), new JacksonJsonDecoder())); + } + } + + private static HttpMessageConverter converter(Encoder encoder, + Decoder decoder) { + return new CodecHttpMessageConverter<>(encoder, decoder); + } + + /** + * Set the list of {@link HttpMessageConverter}s to use for encoding and decoding HTTP + * messages + */ + public void setMessageConverters(List> messageConverters) { + this.messageConverters = messageConverters; + } + + /** + * Perform the actual HTTP request/response exchange + * + *

+ * Requesting from the exposed {@code Flux} will result in: + *

    + *
  • building the actual HTTP request using the provided {@code ClientWebRequestBuilder}
  • + *
  • encoding the HTTP request body with the configured {@code HttpMessageConverter}s
  • + *
  • returning the response with a publisher of the body
  • + *
+ */ + public WebResponseActions perform(ClientWebRequestBuilder builder) { + + ClientWebRequest clientWebRequest = builder.build(); + + final Mono clientResponse = this.clientHttpConnector + .connect(clientWebRequest.getMethod(), clientWebRequest.getUrl(), + new DefaultRequestCallback(clientWebRequest)) + .log("org.springframework.web.client.reactive", Level.FINE); + + return new WebResponseActions() { + @Override + public void doWithStatus(Consumer consumer) { + clientResponse.doOnNext(clientHttpResponse -> + consumer.accept(clientHttpResponse.getStatusCode())); + } + + @Override + public T extract(ResponseExtractor extractor) { + return extractor.extract(clientResponse, messageConverters); + } + }; + } + + protected class DefaultRequestCallback implements Function> { + + private final ClientWebRequest clientWebRequest; + + public DefaultRequestCallback(ClientWebRequest clientWebRequest) { + this.clientWebRequest = clientWebRequest; + } + + @Override + public Mono apply(ClientHttpRequest clientHttpRequest) { + clientHttpRequest.getHeaders().putAll(this.clientWebRequest.getHttpHeaders()); + if (clientHttpRequest.getHeaders().getAccept().isEmpty()) { + clientHttpRequest.getHeaders().setAccept( + Collections.singletonList(MediaType.ALL)); + } + clientWebRequest.getCookies().values() + .stream().flatMap(cookies -> cookies.stream()) + .forEach(cookie -> clientHttpRequest.getCookies().add(cookie.getName(), cookie)); + if (this.clientWebRequest.getBody() != null) { + return writeRequestBody(this.clientWebRequest.getBody(), + this.clientWebRequest.getElementType(), clientHttpRequest, messageConverters); + } + else { + return clientHttpRequest.setComplete(); + } + } + + protected Mono writeRequestBody(Publisher content, + ResolvableType requestType, ClientHttpRequest request, + List> messageConverters) { + + MediaType contentType = request.getHeaders().getContentType(); + Optional> converter = resolveConverter(messageConverters, requestType, contentType); + if (!converter.isPresent()) { + return Mono.error(new IllegalStateException( + "Could not encode request body of type '" + contentType + + "' with target type '" + requestType.toString() + "'")); + } + // noinspection unchecked + return converter.get().write((Publisher) content, requestType, contentType, request); + } + + protected Optional> resolveConverter( + List> messageConverters, ResolvableType type, + MediaType mediaType) { + return messageConverters.stream().filter(e -> e.canWrite(type, mediaType)).findFirst(); + } + } + +} \ No newline at end of file diff --git a/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/WebClientException.java b/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/WebClientException.java new file mode 100644 index 0000000000..4c4ce11fcb --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/WebClientException.java @@ -0,0 +1,36 @@ +/* + * Copyright 2002-2016 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.client.reactive; + +import org.springframework.core.NestedRuntimeException; + +/** + * Base class for exceptions thrown by {@link WebClient} whenever + * it encounters client-side errors. + * + * @author Brian Clozel + */ +public class WebClientException extends NestedRuntimeException { + + public WebClientException(String msg) { + super(msg); + } + + public WebClientException(String msg, Throwable cause) { + super(msg, cause); + } +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/WebResponseActions.java b/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/WebResponseActions.java new file mode 100644 index 0000000000..0c6e709ff1 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/WebResponseActions.java @@ -0,0 +1,49 @@ +/* + * Copyright 2002-2016 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.client.reactive; + +import java.util.function.Consumer; + +import org.springframework.http.HttpStatus; + +/** + * Allows applying actions, such as extractors, on the result of an executed + * {@link WebClient} request. + * + * @author Brian Clozel + */ +public interface WebResponseActions { + + /** + * Apply synchronous operations once the HTTP response status + * has been received. + */ + void doWithStatus(Consumer consumer); + + /** + * Perform an extraction of the response body into a higher level representation. + * + *
+	 * static imports: HttpRequestBuilders.*, HttpResponseExtractors.*
+	 *
+	 * webClient
+	 *   .perform(get(baseUrl.toString()).accept(MediaType.TEXT_PLAIN))
+	 *   .extract(response(String.class));
+	 * 
+ */ + T extract(ResponseExtractor extractor); +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/support/RxJava1ClientWebRequestBuilder.java b/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/support/RxJava1ClientWebRequestBuilder.java new file mode 100644 index 0000000000..d7f6ed4828 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/support/RxJava1ClientWebRequestBuilder.java @@ -0,0 +1,170 @@ +/* + * Copyright 2002-2016 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.client.reactive.support; + +import java.net.URI; + +import reactor.core.converter.RxJava1ObservableConverter; +import reactor.core.converter.RxJava1SingleConverter; +import reactor.core.publisher.Mono; +import rx.Observable; +import rx.Single; + +import org.springframework.core.ResolvableType; +import org.springframework.http.HttpCookie; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.client.reactive.ClientHttpRequest; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.reactive.ClientWebRequest; +import org.springframework.web.client.reactive.ClientWebRequestBuilder; +import org.springframework.web.client.reactive.ClientWebRequestPostProcessor; +import org.springframework.web.client.reactive.DefaultClientWebRequestBuilder; + +/** + * Builds a {@link ClientHttpRequest} using a {@code Observable} + * or {@code Single} as request body. + * + *

See static factory methods in {@link RxJava1ClientWebRequestBuilders} + * + * @author Brian Clozel + * @see RxJava1ClientWebRequestBuilders + */ +public class RxJava1ClientWebRequestBuilder implements ClientWebRequestBuilder { + + private final DefaultClientWebRequestBuilder delegate; + + public RxJava1ClientWebRequestBuilder(HttpMethod httpMethod, String urlTemplate, + Object... urlVariables) throws RestClientException { + this.delegate = new DefaultClientWebRequestBuilder(httpMethod, urlTemplate, urlVariables); + } + + public RxJava1ClientWebRequestBuilder(HttpMethod httpMethod, URI url) { + this.delegate = new DefaultClientWebRequestBuilder(httpMethod, url); + } + + /** + * Add an HTTP request header + */ + public RxJava1ClientWebRequestBuilder header(String name, String... values) { + this.delegate.header(name, values); + return this; + } + + /** + * Add all provided HTTP request headers + */ + public RxJava1ClientWebRequestBuilder headers(HttpHeaders httpHeaders) { + this.delegate.headers(httpHeaders); + return this; + } + + /** + * Set the Content-Type request header to the given {@link MediaType} + */ + public RxJava1ClientWebRequestBuilder contentType(MediaType contentType) { + this.delegate.contentType(contentType); + return this; + } + + /** + * Set the Content-Type request header to the given media type + */ + public RxJava1ClientWebRequestBuilder contentType(String contentType) { + this.delegate.contentType(contentType); + return this; + } + + /** + * Set the Accept request header to the given {@link MediaType}s + */ + public RxJava1ClientWebRequestBuilder accept(MediaType... mediaTypes) { + this.delegate.accept(mediaTypes); + return this; + } + + /** + * Set the Accept request header to the given media types + */ + public RxJava1ClientWebRequestBuilder accept(String... mediaTypes) { + this.delegate.accept(mediaTypes); + return this; + } + + /** + * Add a Cookie to the HTTP request + */ + public RxJava1ClientWebRequestBuilder cookie(String name, String value) { + this.delegate.cookie(name, value); + return this; + } + + /** + * Add a Cookie to the HTTP request + */ + public RxJava1ClientWebRequestBuilder cookie(HttpCookie cookie) { + this.delegate.cookie(cookie); + return this; + } + + /** + * Allows performing more complex operations with a strategy. For example, a + * {@link ClientWebRequestPostProcessor} implementation might accept the arguments of username + * and password and set an HTTP Basic authentication header. + * + * @param postProcessor the {@link ClientWebRequestPostProcessor} to use. Cannot be null. + * + * @return this instance for further modifications. + */ + public RxJava1ClientWebRequestBuilder apply(ClientWebRequestPostProcessor postProcessor) { + this.delegate.apply(postProcessor); + return this; + } + + /** + * Use the given object as the request body + */ + public RxJava1ClientWebRequestBuilder body(Object content) { + this.delegate.body(Mono.just(content), ResolvableType.forInstance(content)); + return this; + } + + /** + * Use the given {@link Single} as the request body and use its {@link ResolvableType} + * as type information for the element published by this reactive stream + */ + public RxJava1ClientWebRequestBuilder body(Single content, ResolvableType elementType) { + this.delegate.body(RxJava1SingleConverter.toPublisher(content), elementType); + return this; + } + + /** + * Use the given {@link Observable} as the request body and use its {@link ResolvableType} + * as type information for the elements published by this reactive stream + */ + public RxJava1ClientWebRequestBuilder body(Observable content, ResolvableType elementType) { + this.delegate.body(RxJava1ObservableConverter.toPublisher(content), elementType); + return this; + } + + @Override + public ClientWebRequest build() { + return this.delegate.build(); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/support/RxJava1ClientWebRequestBuilders.java b/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/support/RxJava1ClientWebRequestBuilders.java new file mode 100644 index 0000000000..4a6a8281b8 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/support/RxJava1ClientWebRequestBuilders.java @@ -0,0 +1,110 @@ +/* + * Copyright 2002-2016 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.client.reactive.support; + +import org.springframework.http.HttpMethod; + +/** + * Static factory methods for {@link RxJava1ClientWebRequestBuilder ClientWebRequestBuilders} + * using the {@link rx.Observable} and {@link rx.Single} API. + * + * @author Brian Clozel + */ +public abstract class RxJava1ClientWebRequestBuilders { + + /** + * Create a {@link RxJava1ClientWebRequestBuilder} for a GET request. + * + * @param urlTemplate a URL template; the resulting URL will be encoded + * @param urlVariables zero or more URL variables + */ + public static RxJava1ClientWebRequestBuilder get(String urlTemplate, Object... urlVariables) { + return new RxJava1ClientWebRequestBuilder(HttpMethod.GET, urlTemplate, urlVariables); + } + + /** + * Create a {@link RxJava1ClientWebRequestBuilder} for a POST request. + * + * @param urlTemplate a URL template; the resulting URL will be encoded + * @param urlVariables zero or more URL variables + */ + public static RxJava1ClientWebRequestBuilder post(String urlTemplate, Object... urlVariables) { + return new RxJava1ClientWebRequestBuilder(HttpMethod.POST, urlTemplate, urlVariables); + } + + + /** + * Create a {@link RxJava1ClientWebRequestBuilder} for a PUT request. + * + * @param urlTemplate a URL template; the resulting URL will be encoded + * @param urlVariables zero or more URL variables + */ + public static RxJava1ClientWebRequestBuilder put(String urlTemplate, Object... urlVariables) { + return new RxJava1ClientWebRequestBuilder(HttpMethod.PUT, urlTemplate, urlVariables); + } + + /** + * Create a {@link RxJava1ClientWebRequestBuilder} for a PATCH request. + * + * @param urlTemplate a URL template; the resulting URL will be encoded + * @param urlVariables zero or more URL variables + */ + public static RxJava1ClientWebRequestBuilder patch(String urlTemplate, Object... urlVariables) { + return new RxJava1ClientWebRequestBuilder(HttpMethod.PATCH, urlTemplate, urlVariables); + } + + /** + * Create a {@link RxJava1ClientWebRequestBuilder} for a DELETE request. + * + * @param urlTemplate a URL template; the resulting URL will be encoded + * @param urlVariables zero or more URL variables + */ + public static RxJava1ClientWebRequestBuilder delete(String urlTemplate, Object... urlVariables) { + return new RxJava1ClientWebRequestBuilder(HttpMethod.DELETE, urlTemplate, urlVariables); + } + + /** + * Create a {@link RxJava1ClientWebRequestBuilder} for an OPTIONS request. + * + * @param urlTemplate a URL template; the resulting URL will be encoded + * @param urlVariables zero or more URL variables + */ + public static RxJava1ClientWebRequestBuilder options(String urlTemplate, Object... urlVariables) { + return new RxJava1ClientWebRequestBuilder(HttpMethod.OPTIONS, urlTemplate, urlVariables); + } + + /** + * Create a {@link RxJava1ClientWebRequestBuilder} for a HEAD request. + * + * @param urlTemplate a URL template; the resulting URL will be encoded + * @param urlVariables zero or more URL variables + */ + public static RxJava1ClientWebRequestBuilder head(String urlTemplate, Object... urlVariables) { + return new RxJava1ClientWebRequestBuilder(HttpMethod.HEAD, urlTemplate, urlVariables); + } + + /** + * Create a {@link RxJava1ClientWebRequestBuilder} for a request with the given HTTP method. + * + * @param httpMethod the HTTP method + * @param urlTemplate a URL template; the resulting URL will be encoded + * @param urlVariables zero or more URL variables + */ + public static RxJava1ClientWebRequestBuilder request(HttpMethod httpMethod, String urlTemplate, Object... urlVariables) { + return new RxJava1ClientWebRequestBuilder(httpMethod, urlTemplate, urlVariables); + } +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/support/RxJava1ResponseExtractors.java b/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/support/RxJava1ResponseExtractors.java new file mode 100644 index 0000000000..96de49f9a0 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/client/reactive/support/RxJava1ResponseExtractors.java @@ -0,0 +1,128 @@ +/* + * Copyright 2002-2016 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.client.reactive.support; + +import java.util.List; +import java.util.Optional; + +import org.springframework.core.ResolvableType; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.reactive.ClientHttpResponse; +import org.springframework.http.converter.reactive.HttpMessageConverter; +import org.springframework.web.client.reactive.ResponseExtractor; + +import reactor.core.converter.RxJava1ObservableConverter; +import reactor.core.converter.RxJava1SingleConverter; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import rx.Observable; +import rx.Single; + +/** + * Static factory methods for {@link ResponseExtractor} + * based on the {@link Observable} and {@link Single} API. + * + * @author Brian Clozel + */ +public class RxJava1ResponseExtractors { + + /** + * Extract the response body and decode it, returning it as a {@code Single} + */ + public static ResponseExtractor> body(Class sourceClass) { + + ResolvableType resolvableType = ResolvableType.forClass(sourceClass); + //noinspection unchecked + return (clientResponse, messageConverters) -> (Single) RxJava1SingleConverter + .fromPublisher(clientResponse + .flatMap(resp -> decodeResponseBody(resp, resolvableType, messageConverters)).next()); + } + + /** + * Extract the response body and decode it, returning it as an {@code Observable} + */ + public static ResponseExtractor> bodyStream(Class sourceClass) { + + ResolvableType resolvableType = ResolvableType.forClass(sourceClass); + return (clientResponse, messageConverters) -> RxJava1ObservableConverter + .fromPublisher(clientResponse + .flatMap(resp -> decodeResponseBody(resp, resolvableType, messageConverters))); + } + + /** + * Extract the full response body as a {@code ResponseEntity} + * with its body decoded as a single type {@code T} + */ + public static ResponseExtractor>> response(Class sourceClass) { + + ResolvableType resolvableType = ResolvableType.forClass(sourceClass); + return (clientResponse, messageConverters) -> (Single>) + RxJava1SingleConverter.fromPublisher(clientResponse + .then(response -> + Mono.when( + decodeResponseBody(response, resolvableType, messageConverters).next(), + Mono.just(response.getHeaders()), + Mono.just(response.getStatusCode()))) + .map(tuple -> { + //noinspection unchecked + return new ResponseEntity<>((T) tuple.getT1(), tuple.getT2(), tuple.getT3()); + })); + } + + /** + * Extract the full response body as a {@code ResponseEntity} + * with its body decoded as an {@code Observable} + */ + public static ResponseExtractor>>> responseStream(Class sourceClass) { + ResolvableType resolvableType = ResolvableType.forClass(sourceClass); + return (clientResponse, messageConverters) -> RxJava1SingleConverter.fromPublisher(clientResponse + .map(response -> new ResponseEntity<>( + RxJava1ObservableConverter + .fromPublisher(decodeResponseBody(response, resolvableType, messageConverters)), + response.getHeaders(), + response.getStatusCode()))); + } + + /** + * Extract the response headers as an {@code HttpHeaders} instance + */ + public static ResponseExtractor> headers() { + return (clientResponse, messageConverters) -> RxJava1SingleConverter + .fromPublisher(clientResponse.map(resp -> resp.getHeaders())); + } + + protected static Flux decodeResponseBody(ClientHttpResponse response, ResolvableType responseType, + List> messageConverters) { + + MediaType contentType = response.getHeaders().getContentType(); + Optional> converter = resolveConverter(messageConverters, responseType, contentType); + if (!converter.isPresent()) { + return Flux.error(new IllegalStateException("Could not decode response body of type '" + contentType + + "' with target type '" + responseType.toString() + "'")); + } + //noinspection unchecked + return (Flux) converter.get().read(responseType, response); + } + + + protected static Optional> resolveConverter(List> messageConverters, + ResolvableType type, MediaType mediaType) { + return messageConverters.stream().filter(e -> e.canRead(type, mediaType)).findFirst(); + } +} \ No newline at end of file diff --git a/spring-web-reactive/src/main/java/org/springframework/web/package-info.java b/spring-web-reactive/src/main/java/org/springframework/web/package-info.java new file mode 100644 index 0000000000..142f096ec7 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/package-info.java @@ -0,0 +1,5 @@ +/** + * Common, generic interfaces that define minimal boundary points + * between Spring's web infrastructure and other framework modules. + */ +package org.springframework.web; diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/DispatcherHandler.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/DispatcherHandler.java new file mode 100644 index 0000000000..095eccfdb1 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/DispatcherHandler.java @@ -0,0 +1,139 @@ +/* + * Copyright 2002-2015 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.reactive; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactoryUtils; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.core.annotation.AnnotationAwareOrderComparator; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebHandler; + +/** + * Central dispatcher for HTTP request handlers/controllers. Dispatches to registered + * handlers for processing a web request, providing convenient mapping facilities. + * + *

It can use any {@link HandlerMapping} implementation to control the routing of + * requests to handler objects. HandlerMapping objects can be defined as beans in + * the application context. + * + *

It can use any {@link HandlerAdapter}; this allows for using any handler interface. + * HandlerAdapter objects can be added as beans in the application context. + * + *

It can use any {@link HandlerResultHandler}; this allows to process the result of + * the request handling. HandlerResultHandler objects can be added as beans in the + * application context. + * + * @author Rossen Stoyanchev + * @author Sebastien Deleuze + */ +public class DispatcherHandler implements WebHandler, ApplicationContextAware { + + private static final Log logger = LogFactory.getLog(DispatcherHandler.class); + + @SuppressWarnings("ThrowableInstanceNeverThrown") + private static final Exception HANDLER_NOT_FOUND_EXCEPTION = + new ResponseStatusException(HttpStatus.NOT_FOUND, "No matching handler"); + + + private List handlerMappings; + + private List handlerAdapters; + + private List resultHandlers; + + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + initStrategies(applicationContext); + } + + + protected void initStrategies(ApplicationContext context) { + + Map mappingBeans = BeanFactoryUtils.beansOfTypeIncludingAncestors( + context, HandlerMapping.class, true, false); + + this.handlerMappings = new ArrayList<>(mappingBeans.values()); + AnnotationAwareOrderComparator.sort(this.handlerMappings); + + Map adapterBeans = BeanFactoryUtils.beansOfTypeIncludingAncestors( + context, HandlerAdapter.class, true, false); + + this.handlerAdapters = new ArrayList<>(adapterBeans.values()); + AnnotationAwareOrderComparator.sort(this.handlerAdapters); + + Map beans = BeanFactoryUtils.beansOfTypeIncludingAncestors( + context, HandlerResultHandler.class, true, false); + + this.resultHandlers = new ArrayList<>(beans.values()); + AnnotationAwareOrderComparator.sort(this.resultHandlers); + } + + + @Override + public Mono handle(ServerWebExchange exchange) { + if (logger.isDebugEnabled()) { + ServerHttpRequest request = exchange.getRequest(); + logger.debug("Processing " + request.getMethod() + " request for [" + request.getURI() + "]"); + } + return Flux.fromIterable(this.handlerMappings) + .concatMap(mapping -> mapping.getHandler(exchange)) + .next() + .otherwiseIfEmpty(Mono.error(HANDLER_NOT_FOUND_EXCEPTION)) + .then(handler -> invokeHandler(exchange, handler)) + .then(result -> handleResult(exchange, result)); + } + + private Mono invokeHandler(ServerWebExchange exchange, Object handler) { + for (HandlerAdapter handlerAdapter : this.handlerAdapters) { + if (handlerAdapter.supports(handler)) { + return handlerAdapter.handle(exchange, handler); + } + } + return Mono.error(new IllegalStateException("No HandlerAdapter: " + handler)); + } + + private Mono handleResult(ServerWebExchange exchange, HandlerResult result) { + return getResultHandler(result).handleResult(exchange, result) + .otherwise(ex -> result.applyExceptionHandler(ex).then(exceptionResult -> + getResultHandler(result).handleResult(exchange, exceptionResult))); + } + + private HandlerResultHandler getResultHandler(HandlerResult handlerResult) { + for (HandlerResultHandler resultHandler : resultHandlers) { + if (resultHandler.supports(handlerResult)) { + return resultHandler; + } + } + throw new IllegalStateException("No HandlerResultHandler for " + handlerResult.getReturnValue()); + } + +} \ No newline at end of file diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/HandlerAdapter.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/HandlerAdapter.java new file mode 100644 index 0000000000..18672faac0 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/HandlerAdapter.java @@ -0,0 +1,63 @@ +/* + * Copyright 2002-2015 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.reactive; + +import java.util.function.Function; + +import reactor.core.publisher.Mono; + +import org.springframework.web.server.ServerWebExchange; + +/** + * Contract that decouples the {@link DispatcherHandler} from the details of + * invoking a handler and makes it possible to support any handler type. + * + * @author Rossen Stoyanchev + * @author Sebastien Deleuze + */ +public interface HandlerAdapter { + + /** + * Whether this {@code HandlerAdapter} supports the given {@code handler}. + * + * @param handler handler object to check + * @return whether or not the handler is supported + */ + boolean supports(Object handler); + + /** + * Handle the request with the given handler. + * + *

Implementations are encouraged to handle exceptions resulting from the + * invocation of a handler in order and if necessary to return an alternate + * result that represents an error response. + * + *

Furthermore since an async {@code HandlerResult} may produce an error + * later during result handling implementations are also encouraged to + * {@link HandlerResult#setExceptionHandler(Function) set an exception + * handler} on the {@code HandlerResult} so that may also be applied later + * after result handling. + * + * @param exchange current server exchange + * @param handler the selected handler which must have been previously + * checked via {@link #supports(Object)} + * @return {@link Mono} that emits a single {@code HandlerResult} or none if + * the request has been fully handled and doesn't require further handling. + */ + Mono handle(ServerWebExchange exchange, Object handler); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/HandlerMapping.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/HandlerMapping.java new file mode 100644 index 0000000000..45d33469cb --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/HandlerMapping.java @@ -0,0 +1,90 @@ +/* + * Copyright 2002-2015 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.reactive; + +import reactor.core.publisher.Mono; + +import org.springframework.web.server.ServerWebExchange; + +/** + * Interface to be implemented by objects that define a mapping between + * requests and handler objects. + * + * @author Rossen Stoyanchev + * @author Sebastien Deleuze + */ +public interface HandlerMapping { + + /** + * Name of the {@link ServerWebExchange} attribute that contains the + * best matching pattern within the handler mapping. + *

Note: This attribute is not required to be supported by all + * HandlerMapping implementations. URL-based HandlerMappings will + * typically support it, but handlers should not necessarily expect + * this request attribute to be present in all scenarios. + */ + String BEST_MATCHING_PATTERN_ATTRIBUTE = HandlerMapping.class.getName() + ".bestMatchingPattern"; + + /** + * Name of the {@link ServerWebExchange} attribute that contains the path + * within the handler mapping, in case of a pattern match, or the full + * relevant URI (typically within the DispatcherServlet's mapping) else. + *

Note: This attribute is not required to be supported by all + * HandlerMapping implementations. URL-based HandlerMappings will + * typically support it, but handlers should not necessarily expect + * this request attribute to be present in all scenarios. + */ + String PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE = HandlerMapping.class.getName() + ".pathWithinHandlerMapping"; + + /** + * Name of the {@link ServerWebExchange} attribute that contains the URI + * templates map, mapping variable names to values. + *

Note: This attribute is not required to be supported by all + * HandlerMapping implementations. URL-based HandlerMappings will + * typically support it, but handlers should not necessarily expect + * this request attribute to be present in all scenarios. + */ + String URI_TEMPLATE_VARIABLES_ATTRIBUTE = HandlerMapping.class.getName() + ".uriTemplateVariables"; + + /** + * Name of the {@link ServerWebExchange} attribute that contains a map with + * URI matrix variables. + *

Note: This attribute is not required to be supported by all + * HandlerMapping implementations and may also not be present depending on + * whether the HandlerMapping is configured to keep matrix variable content + * in the request URI. + */ + String MATRIX_VARIABLES_ATTRIBUTE = HandlerMapping.class.getName() + ".matrixVariables"; + + /** + * Name of the {@link ServerWebExchange} attribute that contains the set of + * producible MediaTypes applicable to the mapped handler. + *

Note: This attribute is not required to be supported by all + * HandlerMapping implementations. Handlers should not necessarily expect + * this request attribute to be present in all scenarios. + */ + String PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE = HandlerMapping.class.getName() + ".producibleMediaTypes"; + + /** + * Return a handler for this request. + * @param exchange current server exchange + * @return A {@link Mono} that emits one value or none in case the request + * cannot be resolved to a handler + */ + Mono getHandler(ServerWebExchange exchange); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/HandlerResult.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/HandlerResult.java new file mode 100644 index 0000000000..f4416eda0e --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/HandlerResult.java @@ -0,0 +1,141 @@ +/* + * Copyright 2002-2015 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.reactive; + +import java.util.Optional; +import java.util.function.Function; + +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.core.ResolvableType; +import org.springframework.ui.ExtendedModelMap; +import org.springframework.ui.ModelMap; +import org.springframework.util.Assert; + +/** + * Represent the result of the invocation of a handler. + * + * @author Rossen Stoyanchev + */ +public class HandlerResult { + + private final Object handler; + + @SuppressWarnings("OptionalUsedAsFieldOrParameterType") + private final Optional returnValue; + + private final ResolvableType returnType; + + private final ModelMap model; + + private Function> exceptionHandler; + + + /** + * Create a new {@code HandlerResult}. + * @param handler the handler that handled the request + * @param returnValue the return value from the handler possibly {@code null} + * @param returnType the return value type + */ + public HandlerResult(Object handler, Object returnValue, MethodParameter returnType) { + this(handler, returnValue, returnType, null); + } + + /** + * Create a new {@code HandlerResult}. + * @param handler the handler that handled the request + * @param returnValue the return value from the handler possibly {@code null} + * @param returnType the return value type + * @param model the model used for request handling + */ + public HandlerResult(Object handler, Object returnValue, MethodParameter returnType, ModelMap model) { + Assert.notNull(handler, "'handler' is required"); + Assert.notNull(returnType, "'returnType' is required"); + this.handler = handler; + this.returnValue = Optional.ofNullable(returnValue); + this.returnType = ResolvableType.forMethodParameter(returnType); + this.model = (model != null ? model : new ExtendedModelMap()); + } + + + /** + * Return the handler that handled the request. + */ + public Object getHandler() { + return this.handler; + } + + /** + * Return the value returned from the handler wrapped as {@link Optional}. + */ + public Optional getReturnValue() { + return this.returnValue; + } + + /** + * Return the type of the value returned from the handler. + */ + public ResolvableType getReturnType() { + return this.returnType; + } + + /** + * Return the {@link MethodParameter} from which + * {@link #getReturnType() returnType} was created. + */ + public MethodParameter getReturnTypeSource() { + return (MethodParameter) this.returnType.getSource(); + } + + /** + * Return the model used during request handling with attributes that may be + * used to render HTML templates with. + */ + public ModelMap getModel() { + return this.model; + } + + /** + * Configure an exception handler that may be used to produce an alternative + * result when result handling fails. Especially for an async return value + * errors may occur after the invocation of the handler. + * @param function the error handler + * @return the current instance + */ + public HandlerResult setExceptionHandler(Function> function) { + this.exceptionHandler = function; + return this; + } + + /** + * Whether there is an exception handler. + */ + public boolean hasExceptionHandler() { + return (this.exceptionHandler != null); + } + + /** + * Apply the exception handler and return the alternative result. + * @param failure the exception + * @return the new result or the same error if there is no exception handler + */ + public Mono applyExceptionHandler(Throwable failure) { + return (hasExceptionHandler() ? this.exceptionHandler.apply(failure) : Mono.error(failure)); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/HandlerResultHandler.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/HandlerResultHandler.java new file mode 100644 index 0000000000..c2038b3e3c --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/HandlerResultHandler.java @@ -0,0 +1,49 @@ +/* + * Copyright 2002-2015 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.reactive; + +import reactor.core.publisher.Mono; + +import org.springframework.web.server.ServerWebExchange; + +/** + * Process the {@link HandlerResult}, usually returned by an {@link HandlerAdapter}. + * + * @author Rossen Stoyanchev + * @author Sebastien Deleuze + */ +public interface HandlerResultHandler { + + /** + * Whether this handler supports the given {@link HandlerResult}. + * + * @param result result object to check + * @return whether or not this object can use the given result + */ + boolean supports(HandlerResult result); + + /** + * Process the given result modifying response headers and/or writing data + * to the response. + * + * @param exchange current server exchange + * @param result the result from the handling + * @return {@code Mono} to indicate when request handling is complete. + */ + Mono handleResult(ServerWebExchange exchange, HandlerResult result); + +} \ No newline at end of file diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/ResponseStatusExceptionHandler.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/ResponseStatusExceptionHandler.java new file mode 100644 index 0000000000..6a931a01a1 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/ResponseStatusExceptionHandler.java @@ -0,0 +1,41 @@ +/* + * Copyright 2002-2015 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.reactive; + +import reactor.core.publisher.Mono; + +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.server.WebExceptionHandler; +import org.springframework.web.server.ServerWebExchange; + +/** + * Handle {@link ResponseStatusException} by setting the response status. + * + * @author Rossen Stoyanchev + */ +public class ResponseStatusExceptionHandler implements WebExceptionHandler { + + + @Override + public Mono handle(ServerWebExchange exchange, Throwable ex) { + if (ex instanceof ResponseStatusException) { + exchange.getResponse().setStatusCode(((ResponseStatusException) ex).getStatus()); + return Mono.empty(); + } + return Mono.error(ex); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/AbstractMappingContentTypeResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/AbstractMappingContentTypeResolver.java new file mode 100644 index 0000000000..a1f993f9ca --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/AbstractMappingContentTypeResolver.java @@ -0,0 +1,157 @@ +/* + * Copyright 2002-2016 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.reactive.accept; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import org.springframework.http.MediaType; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.server.NotAcceptableStatusException; +import org.springframework.web.server.ServerWebExchange; + +/** + * Abstract base class for {@link MappingContentTypeResolver} implementations. + * Maintains the actual mappings and pre-implements the overall algorithm with + * sub-classes left to provide a way to extract the lookup key (e.g. file + * extension, query parameter, etc) for a given exchange. + * + * @author Rossen Stoyanchev + */ +public abstract class AbstractMappingContentTypeResolver implements MappingContentTypeResolver { + + /** Primary lookup for media types by key (e.g. "json" -> "application/json") */ + private final ConcurrentMap mediaTypeLookup = new ConcurrentHashMap<>(64); + + /** Reverse lookup for keys associated with a media type */ + private final MultiValueMap keyLookup = new LinkedMultiValueMap<>(64); + + + /** + * Create an instance with the given map of file extensions and media types. + */ + public AbstractMappingContentTypeResolver(Map mediaTypes) { + if (mediaTypes != null) { + for (Map.Entry entry : mediaTypes.entrySet()) { + String extension = entry.getKey().toLowerCase(Locale.ENGLISH); + MediaType mediaType = entry.getValue(); + this.mediaTypeLookup.put(extension, mediaType); + this.keyLookup.add(mediaType, extension); + } + } + } + + + /** + * Sub-classes can use this method to look up a MediaType by key. + * @param key the key converted to lower case + * @return a MediaType or {@code null} + */ + protected MediaType getMediaType(String key) { + return this.mediaTypeLookup.get(key.toLowerCase(Locale.ENGLISH)); + } + + /** + * Sub-classes can use this method get all mapped media types. + */ + protected List getMediaTypes() { + return new ArrayList<>(this.mediaTypeLookup.values()); + } + + + // RequestedContentTypeResolver implementation + + @Override + public List resolveMediaTypes(ServerWebExchange exchange) + throws NotAcceptableStatusException { + + String key = extractKey(exchange); + return resolveMediaTypes(key); + } + + /** + * An overloaded resolve method with a pre-resolved lookup key. + * @param key the key for looking up media types + * @return a list of resolved media types or an empty list + * @throws NotAcceptableStatusException + */ + public List resolveMediaTypes(String key) throws NotAcceptableStatusException { + if (StringUtils.hasText(key)) { + MediaType mediaType = getMediaType(key); + if (mediaType != null) { + handleMatch(key, mediaType); + return Collections.singletonList(mediaType); + } + mediaType = handleNoMatch(key); + if (mediaType != null) { + MediaType previous = this.mediaTypeLookup.putIfAbsent(key, mediaType); + if (previous == null) { + this.keyLookup.add(mediaType, key); + } + return Collections.singletonList(mediaType); + } + } + return Collections.emptyList(); + } + + /** + * Extract the key to use to look up a media type from the given exchange, + * e.g. file extension, query parameter, etc. + * @return the key or {@code null} + */ + protected abstract String extractKey(ServerWebExchange exchange); + + /** + * Override to provide handling when a key is successfully resolved via + * {@link #getMediaType(String)}. + */ + @SuppressWarnings("UnusedParameters") + protected void handleMatch(String key, MediaType mediaType) { + } + + /** + * Override to provide handling when a key is not resolved via. + * {@link #getMediaType(String)}. If a MediaType is returned from + * this method it will be added to the mappings. + */ + @SuppressWarnings("UnusedParameters") + protected MediaType handleNoMatch(String key) throws NotAcceptableStatusException { + return null; + } + + // MappingContentTypeResolver implementation + + @Override + public Set getKeysFor(MediaType mediaType) { + List keys = this.keyLookup.get(mediaType); + return (keys != null ? new HashSet<>(keys) : Collections.emptySet()); + } + + @Override + public Set getKeys() { + return new HashSet<>(this.mediaTypeLookup.keySet()); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/CompositeContentTypeResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/CompositeContentTypeResolver.java new file mode 100644 index 0000000000..a60a914512 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/CompositeContentTypeResolver.java @@ -0,0 +1,105 @@ +/* + * Copyright 2002-2016 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.reactive.accept; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.springframework.http.MediaType; +import org.springframework.util.Assert; +import org.springframework.web.server.NotAcceptableStatusException; +import org.springframework.web.server.ServerWebExchange; + +/** + * A {@link RequestedContentTypeResolver} that contains and delegates to a list of other + * resolvers. + * + *

Also an implementation of {@link MappingContentTypeResolver} that delegates + * to those resolvers from the list that are also of type + * {@code MappingContentTypeResolver}. + * + * @author Rossen Stoyanchev + */ +public class CompositeContentTypeResolver implements MappingContentTypeResolver { + + private final List resolvers = new ArrayList<>(); + + + public CompositeContentTypeResolver(List resolvers) { + Assert.notEmpty(resolvers, "At least one resolver is expected."); + this.resolvers.addAll(resolvers); + } + + + /** + * Return a read-only list of the configured resolvers. + */ + public List getResolvers() { + return Collections.unmodifiableList(this.resolvers); + } + + /** + * Return the first {@link RequestedContentTypeResolver} of the given type. + * @param resolverType the resolver type + * @return the first matching resolver or {@code null}. + */ + @SuppressWarnings("unchecked") + public T findResolver(Class resolverType) { + for (RequestedContentTypeResolver resolver : this.resolvers) { + if (resolverType.isInstance(resolver)) { + return (T) resolver; + } + } + return null; + } + + + @Override + public List resolveMediaTypes(ServerWebExchange exchange) throws NotAcceptableStatusException { + for (RequestedContentTypeResolver resolver : this.resolvers) { + List mediaTypes = resolver.resolveMediaTypes(exchange); + if (mediaTypes.isEmpty() || (mediaTypes.size() == 1 && mediaTypes.contains(MediaType.ALL))) { + continue; + } + return mediaTypes; + } + return Collections.emptyList(); + } + + @Override + public Set getKeysFor(MediaType mediaType) { + Set result = new LinkedHashSet<>(); + for (RequestedContentTypeResolver resolver : this.resolvers) { + if (resolver instanceof MappingContentTypeResolver) + result.addAll(((MappingContentTypeResolver) resolver).getKeysFor(mediaType)); + } + return result; + } + + @Override + public Set getKeys() { + Set result = new LinkedHashSet<>(); + for (RequestedContentTypeResolver resolver : this.resolvers) { + if (resolver instanceof MappingContentTypeResolver) + result.addAll(((MappingContentTypeResolver) resolver).getKeys()); + } + return result; + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/FixedContentTypeResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/FixedContentTypeResolver.java new file mode 100644 index 0000000000..5f145945a2 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/FixedContentTypeResolver.java @@ -0,0 +1,48 @@ +/* + * Copyright 2002-2015 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.reactive.accept; + +import java.util.Collections; +import java.util.List; + +import org.springframework.http.MediaType; +import org.springframework.web.server.ServerWebExchange; + +/** + * A {@link RequestedContentTypeResolver} that resolves to a fixed list of media types. + * + * @author Rossen Stoyanchev + */ +public class FixedContentTypeResolver implements RequestedContentTypeResolver { + + private final List mediaTypes; + + + /** + * Create an instance with the given content type. + */ + public FixedContentTypeResolver(MediaType mediaTypes) { + this.mediaTypes = Collections.singletonList(mediaTypes); + } + + + @Override + public List resolveMediaTypes(ServerWebExchange exchange) { + return this.mediaTypes; + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/HeaderContentTypeResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/HeaderContentTypeResolver.java new file mode 100644 index 0000000000..f6c2f04865 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/HeaderContentTypeResolver.java @@ -0,0 +1,46 @@ +/* + * Copyright 2002-2016 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.reactive.accept; + +import java.util.List; + +import org.springframework.http.InvalidMediaTypeException; +import org.springframework.http.MediaType; +import org.springframework.web.server.NotAcceptableStatusException; +import org.springframework.web.server.ServerWebExchange; + +/** + * A {@link RequestedContentTypeResolver} that checks the 'Accept' request header. + * + * @author Rossen Stoyanchev + */ +public class HeaderContentTypeResolver implements RequestedContentTypeResolver { + + @Override + public List resolveMediaTypes(ServerWebExchange exchange) throws NotAcceptableStatusException { + try { + List mediaTypes = exchange.getRequest().getHeaders().getAccept(); + MediaType.sortBySpecificityAndQuality(mediaTypes); + return mediaTypes; + } + catch (InvalidMediaTypeException ex) { + String value = exchange.getRequest().getHeaders().getFirst("Accept"); + throw new NotAcceptableStatusException( + "Could not parse 'Accept' header [" + value + "]: " + ex.getMessage()); + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/MappingContentTypeResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/MappingContentTypeResolver.java new file mode 100644 index 0000000000..64bd272b1c --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/MappingContentTypeResolver.java @@ -0,0 +1,45 @@ +/* + * Copyright 2002-2016 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.reactive.accept; + +import java.util.Set; + +import org.springframework.http.MediaType; + +/** + * An extension of {@link RequestedContentTypeResolver} that maintains a mapping between + * keys (e.g. file extension, query parameter) and media types. + * + * @author Rossen Stoyanchev + */ +public interface MappingContentTypeResolver extends RequestedContentTypeResolver { + + /** + * Resolve the given media type to a list of path extensions. + * + * @param mediaType the media type to resolve + * @return a list of extensions or an empty list, never {@code null} + */ + Set getKeysFor(MediaType mediaType); + + /** + * Return all registered keys (e.g. "json", "xml"). + * @return a list of keys or an empty list, never {@code null} + */ + Set getKeys(); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/ParameterContentTypeResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/ParameterContentTypeResolver.java new file mode 100644 index 0000000000..63971b7e83 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/ParameterContentTypeResolver.java @@ -0,0 +1,82 @@ +/* + * Copyright 2002-2016 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.reactive.accept; + +import java.util.Map; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.http.MediaType; +import org.springframework.util.Assert; +import org.springframework.web.server.NotAcceptableStatusException; +import org.springframework.web.server.ServerWebExchange; + +/** + * A {@link RequestedContentTypeResolver} that extracts the media type lookup key from a + * known query parameter named "format" by default. + *s + * @author Rossen Stoyanchev + */ +public class ParameterContentTypeResolver extends AbstractMappingContentTypeResolver { + + private static final Log logger = LogFactory.getLog(ParameterContentTypeResolver.class); + + private String parameterName = "format"; + + + /** + * Create an instance with the given map of file extensions and media types. + */ + public ParameterContentTypeResolver(Map mediaTypes) { + super(mediaTypes); + } + + + /** + * Set the name of the parameter to use to determine requested media types. + *

By default this is set to {@code "format"}. + */ + public void setParameterName(String parameterName) { + Assert.notNull(parameterName, "parameterName is required"); + this.parameterName = parameterName; + } + + public String getParameterName() { + return this.parameterName; + } + + + @Override + protected String extractKey(ServerWebExchange exchange) { + return exchange.getRequest().getQueryParams().getFirst(getParameterName()); + } + + @Override + protected void handleMatch(String mediaTypeKey, MediaType mediaType) { + if (logger.isDebugEnabled()) { + logger.debug("Requested media type is '" + mediaType + + "' based on '" + getParameterName() + "'='" + mediaTypeKey + "'."); + } + } + + @Override + protected MediaType handleNoMatch(String key) throws NotAcceptableStatusException { + throw new NotAcceptableStatusException(getMediaTypes()); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/PathExtensionContentTypeResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/PathExtensionContentTypeResolver.java new file mode 100644 index 0000000000..d9f07e2503 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/PathExtensionContentTypeResolver.java @@ -0,0 +1,135 @@ +/* + * Copyright 2002-2016 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.reactive.accept; + +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +import org.springframework.core.io.Resource; +import org.springframework.http.MediaType; +import org.springframework.http.support.MediaTypeUtils; +import org.springframework.util.Assert; +import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils2; +import org.springframework.util.StringUtils; +import org.springframework.web.server.NotAcceptableStatusException; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.util.WebUtils; + +/** + * A {@link RequestedContentTypeResolver} that extracts the file extension from the + * request path and uses that as the media type lookup key. + * + *

If the file extension is not found in the explicit registrations provided + * to the constructor, the Java Activation Framework (JAF) is used as a fallback + * mechanism. The presence of the JAF is detected and enabled automatically but + * the {@link #setUseJaf(boolean)} property may be set to false. + * + * @author Rossen Stoyanchev + */ +public class PathExtensionContentTypeResolver extends AbstractMappingContentTypeResolver { + + private boolean useJaf = true; + + private boolean ignoreUnknownExtensions = true; + + + /** + * Create an instance with the given map of file extensions and media types. + */ + public PathExtensionContentTypeResolver(Map mediaTypes) { + super(mediaTypes); + } + + /** + * Create an instance without any mappings to start with. Mappings may be added + * later on if any extensions are resolved through the Java Activation framework. + */ + public PathExtensionContentTypeResolver() { + super(null); + } + + + /** + * Whether to use the Java Activation Framework to look up file extensions. + *

By default this is set to "true" but depends on JAF being present. + */ + public void setUseJaf(boolean useJaf) { + this.useJaf = useJaf; + } + + /** + * Whether to ignore requests with unknown file extension. Setting this to + * {@code false} results in {@code HttpMediaTypeNotAcceptableException}. + *

By default this is set to {@code true}. + */ + public void setIgnoreUnknownExtensions(boolean ignoreUnknownExtensions) { + this.ignoreUnknownExtensions = ignoreUnknownExtensions; + } + + + @Override + protected String extractKey(ServerWebExchange exchange) { + String path = exchange.getRequest().getURI().getRawPath(); + String filename = WebUtils.extractFullFilenameFromUrlPath(path); + String extension = StringUtils.getFilenameExtension(filename); + return (StringUtils.hasText(extension)) ? extension.toLowerCase(Locale.ENGLISH) : null; + } + + @Override + protected MediaType handleNoMatch(String key) throws NotAcceptableStatusException { + if (this.useJaf) { + Optional mimeType = MimeTypeUtils2.getMimeType("file." + key); + MediaType mediaType = mimeType.map(MediaTypeUtils::toMediaType).orElse(null); + if (mediaType != null && !MediaType.APPLICATION_OCTET_STREAM.equals(mediaType)) { + return mediaType; + } + } + if (!this.ignoreUnknownExtensions) { + throw new NotAcceptableStatusException(getMediaTypes()); + } + return null; + } + + /** + * A public method exposing the knowledge of the path extension resolver to + * determine the media type for a given {@link Resource}. First it checks + * the explicitly registered mappings and then falls back on JAF. + * @param resource the resource + * @return the MediaType for the extension or {@code null}. + */ + public MediaType resolveMediaTypeForResource(Resource resource) { + Assert.notNull(resource); + MediaType mediaType = null; + String filename = resource.getFilename(); + String extension = StringUtils.getFilenameExtension(filename); + if (extension != null) { + mediaType = getMediaType(extension); + } + if (mediaType == null) { + mediaType = + MimeTypeUtils2.getMimeType(filename).map(MediaTypeUtils::toMediaType) + .orElse(null); + } + if (MediaType.APPLICATION_OCTET_STREAM.equals(mediaType)) { + mediaType = null; + } + return mediaType; + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/RequestedContentTypeResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/RequestedContentTypeResolver.java new file mode 100644 index 0000000000..c2476f1b11 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/RequestedContentTypeResolver.java @@ -0,0 +1,43 @@ +/* + * Copyright 2002-2016 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.reactive.accept; + +import java.util.List; + +import org.springframework.http.MediaType; +import org.springframework.web.server.NotAcceptableStatusException; +import org.springframework.web.server.ServerWebExchange; + +/** + * Strategy for resolving the requested media types for a {@code ServerWebExchange}. + * + * @author Rossen Stoyanchev + */ +public interface RequestedContentTypeResolver { + + /** + * Resolve the given request to a list of requested media types. The returned + * list is ordered by specificity first and by quality parameter second. + * + * @param exchange the current exchange + * @return the requested media types or an empty list + * + * @throws NotAcceptableStatusException if the requested media types is invalid + */ + List resolveMediaTypes(ServerWebExchange exchange) + throws NotAcceptableStatusException; + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/RequestedContentTypeResolverBuilder.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/RequestedContentTypeResolverBuilder.java new file mode 100644 index 0000000000..d11cbf64e8 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/RequestedContentTypeResolverBuilder.java @@ -0,0 +1,248 @@ +/* + * Copyright 2002-2016 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.reactive.accept; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.springframework.http.MediaType; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; + + +/** + * Factory to create a {@link CompositeContentTypeResolver} and configure it with + * one or more {@link RequestedContentTypeResolver} instances with build style methods. + * The following table shows methods, resulting strategy instances, and if in + * use by default: + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
Property SetterUnderlying StrategyDefault Setting
{@link #favorPathExtension}{@link PathExtensionContentTypeResolver Path Extension resolver}On
{@link #favorParameter}{@link ParameterContentTypeResolver Parameter resolver}Off
{@link #ignoreAcceptHeader}{@link HeaderContentTypeResolver Header resolver}On
{@link #defaultContentType}{@link FixedContentTypeResolver Fixed content resolver}Not set
{@link #defaultContentTypeResolver}{@link RequestedContentTypeResolver}Not set
+ * + *

The order in which resolvers are configured is fixed. Config methods may only + * turn individual resolvers on or off. If you need a custom order for any + * reason simply instantiate {@code {@link CompositeContentTypeResolver}} directly. + * + *

For the path extension and parameter resolvers you may explicitly add + * {@link #mediaTypes(Map)}. This will be used to resolve path extensions or a + * parameter value such as "json" to a media type such as "application/json". + * + *

The path extension strategy will also use the Java Activation framework + * (JAF), if available, to resolve a path extension to a MediaType. You may + * {@link #useJaf suppress} the use of JAF. + * + * @author Rossen Stoyanchev + */ +public class RequestedContentTypeResolverBuilder { + + private boolean favorPathExtension = true; + + private boolean favorParameter = false; + + private boolean ignoreAcceptHeader = false; + + private Map mediaTypes = new HashMap<>(); + + private boolean ignoreUnknownPathExtensions = true; + + private Boolean useJaf; + + private String parameterName = "format"; + + private RequestedContentTypeResolver contentTypeResolver; + + + /** + * Whether the path extension in the URL path should be used to determine + * the requested media type. + *

By default this is set to {@code true} in which case a request + * for {@code /hotels.pdf} will be interpreted as a request for + * {@code "application/pdf"} regardless of the 'Accept' header. + */ + public RequestedContentTypeResolverBuilder favorPathExtension(boolean favorPathExtension) { + this.favorPathExtension = favorPathExtension; + return this; + } + + /** + * Add a mapping from a key, extracted from a path extension or a query + * parameter, to a MediaType. This is required in order for the parameter + * strategy to work. Any extensions explicitly registered here are also + * whitelisted for the purpose of Reflected File Download attack detection + * (see Spring Framework reference documentation for more details on RFD + * attack protection). + *

The path extension strategy will also try to use JAF (if present) to + * resolve path extensions. To change this behavior see {@link #useJaf}. + * @param mediaTypes media type mappings + */ + public RequestedContentTypeResolverBuilder mediaTypes(Map mediaTypes) { + if (!CollectionUtils.isEmpty(mediaTypes)) { + for (Map.Entry entry : mediaTypes.entrySet()) { + String extension = entry.getKey().toLowerCase(Locale.ENGLISH); + this.mediaTypes.put(extension, entry.getValue()); + } + } + return this; + } + + /** + * Alternative to {@link #mediaTypes} to add a single mapping. + */ + public RequestedContentTypeResolverBuilder mediaType(String key, MediaType mediaType) { + this.mediaTypes.put(key, mediaType); + return this; + } + + /** + * Whether to ignore requests with path extension that cannot be resolved + * to any media type. Setting this to {@code false} will result in an + * {@link org.springframework.web.HttpMediaTypeNotAcceptableException} if + * there is no match. + *

By default this is set to {@code true}. + */ + public RequestedContentTypeResolverBuilder ignoreUnknownPathExtensions(boolean ignore) { + this.ignoreUnknownPathExtensions = ignore; + return this; + } + + /** + * When {@link #favorPathExtension favorPathExtension} is set, this + * property determines whether to allow use of JAF (Java Activation Framework) + * to resolve a path extension to a specific MediaType. + *

By default this is not set in which case + * {@code PathExtensionContentNegotiationStrategy} will use JAF if available. + */ + public RequestedContentTypeResolverBuilder useJaf(boolean useJaf) { + this.useJaf = useJaf; + return this; + } + + /** + * Whether a request parameter ("format" by default) should be used to + * determine the requested media type. For this option to work you must + * register {@link #mediaTypes media type mappings}. + *

By default this is set to {@code false}. + * @see #parameterName + */ + public RequestedContentTypeResolverBuilder favorParameter(boolean favorParameter) { + this.favorParameter = favorParameter; + return this; + } + + /** + * Set the query parameter name to use when {@link #favorParameter} is on. + *

The default parameter name is {@code "format"}. + */ + public RequestedContentTypeResolverBuilder parameterName(String parameterName) { + Assert.notNull(parameterName, "parameterName is required"); + this.parameterName = parameterName; + return this; + } + + /** + * Whether to disable checking the 'Accept' request header. + *

By default this value is set to {@code false}. + */ + public RequestedContentTypeResolverBuilder ignoreAcceptHeader(boolean ignoreAcceptHeader) { + this.ignoreAcceptHeader = ignoreAcceptHeader; + return this; + } + + /** + * Set the default content type to use when no content type is requested. + *

By default this is not set. + * @see #defaultContentTypeResolver + */ + public RequestedContentTypeResolverBuilder defaultContentType(MediaType contentType) { + this.contentTypeResolver = new FixedContentTypeResolver(contentType); + return this; + } + + /** + * Set a custom {@link RequestedContentTypeResolver} to use to determine + * the content type to use when no content type is requested. + *

By default this is not set. + * @see #defaultContentType + */ + public RequestedContentTypeResolverBuilder defaultContentTypeResolver(RequestedContentTypeResolver resolver) { + this.contentTypeResolver = resolver; + return this; + } + + + public RequestedContentTypeResolver build() { + List resolvers = new ArrayList<>(); + + if (this.favorPathExtension) { + PathExtensionContentTypeResolver resolver = new PathExtensionContentTypeResolver(this.mediaTypes); + resolver.setIgnoreUnknownExtensions(this.ignoreUnknownPathExtensions); + if (this.useJaf != null) { + resolver.setUseJaf(this.useJaf); + } + resolvers.add(resolver); + } + + if (this.favorParameter) { + ParameterContentTypeResolver resolver = new ParameterContentTypeResolver(this.mediaTypes); + resolver.setParameterName(this.parameterName); + resolvers.add(resolver); + } + + if (!this.ignoreAcceptHeader) { + resolvers.add(new HeaderContentTypeResolver()); + } + + if (this.contentTypeResolver != null) { + resolvers.add(this.contentTypeResolver); + } + + return new CompositeContentTypeResolver(resolvers); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/package-info.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/package-info.java new file mode 100644 index 0000000000..428353ad05 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/accept/package-info.java @@ -0,0 +1,5 @@ +/** + * This package provides support for various strategies to resolve the requested + * content type for a given request. + */ +package org.springframework.web.reactive.accept; diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/config/PathMatchConfigurer.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/config/PathMatchConfigurer.java new file mode 100644 index 0000000000..c45565372a --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/config/PathMatchConfigurer.java @@ -0,0 +1,110 @@ +/* + * Copyright 2002-2016 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.reactive.config; + +import org.springframework.util.PathMatcher; +import org.springframework.web.util.HttpRequestPathHelper; + +/** + * Assist with configuring {@code HandlerMapping}'s with path matching options. + * + * @author Rossen Stoyanchev + */ +public class PathMatchConfigurer { + + private Boolean suffixPatternMatch; + + private Boolean trailingSlashMatch; + + private Boolean registeredSuffixPatternMatch; + + private HttpRequestPathHelper pathHelper; + + private PathMatcher pathMatcher; + + + /** + * Whether to use suffix pattern match (".*") when matching patterns to + * requests. If enabled a method mapped to "/users" also matches to "/users.*". + *

By default this is set to {@code true}. + * @see #registeredSuffixPatternMatch + */ + public PathMatchConfigurer setUseSuffixPatternMatch(Boolean suffixPatternMatch) { + this.suffixPatternMatch = suffixPatternMatch; + return this; + } + + /** + * Whether to match to URLs irrespective of the presence of a trailing slash. + * If enabled a method mapped to "/users" also matches to "/users/". + *

The default value is {@code true}. + */ + public PathMatchConfigurer setUseTrailingSlashMatch(Boolean trailingSlashMatch) { + this.trailingSlashMatch = trailingSlashMatch; + return this; + } + + /** + * Whether suffix pattern matching should work only against path extensions + * that are explicitly registered. This is generally recommended to reduce + * ambiguity and to avoid issues such as when a "." (dot) appears in the path + * for other reasons. + *

By default this is set to "true". + */ + public PathMatchConfigurer setUseRegisteredSuffixPatternMatch(Boolean registeredSuffixPatternMatch) { + this.registeredSuffixPatternMatch = registeredSuffixPatternMatch; + return this; + } + + /** + * Set a {@code HttpRequestPathHelper} for the resolution of lookup paths. + *

Default is {@code HttpRequestPathHelper}. + */ + public PathMatchConfigurer setPathHelper(HttpRequestPathHelper pathHelper) { + this.pathHelper = pathHelper; + return this; + } + + /** + * Set the PathMatcher for matching URL paths against registered URL patterns. + *

Default is {@link org.springframework.util.AntPathMatcher AntPathMatcher}. + */ + public PathMatchConfigurer setPathMatcher(PathMatcher pathMatcher) { + this.pathMatcher = pathMatcher; + return this; + } + + protected Boolean isUseSuffixPatternMatch() { + return this.suffixPatternMatch; + } + + protected Boolean isUseTrailingSlashMatch() { + return this.trailingSlashMatch; + } + + protected Boolean isUseRegisteredSuffixPatternMatch() { + return this.registeredSuffixPatternMatch; + } + + protected HttpRequestPathHelper getPathHelper() { + return this.pathHelper; + } + + protected PathMatcher getPathMatcher() { + return this.pathMatcher; + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/config/UrlBasedViewResolverRegistration.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/config/UrlBasedViewResolverRegistration.java new file mode 100644 index 0000000000..422449d753 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/config/UrlBasedViewResolverRegistration.java @@ -0,0 +1,79 @@ +/* + * Copyright 2002-2016 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.reactive.config; + +import org.springframework.util.Assert; +import org.springframework.web.reactive.result.view.UrlBasedViewResolver; + +/** + * Assist with configuring properties of a {@link UrlBasedViewResolver}. + * + * @author Rossen Stoyanchev + */ +public class UrlBasedViewResolverRegistration { + + private final UrlBasedViewResolver viewResolver; + + + public UrlBasedViewResolverRegistration(UrlBasedViewResolver viewResolver) { + Assert.notNull(viewResolver); + this.viewResolver = viewResolver; + } + + + /** + * Set the prefix that gets prepended to view names when building a URL. + * @see UrlBasedViewResolver#setPrefix + */ + public UrlBasedViewResolverRegistration prefix(String prefix) { + this.viewResolver.setPrefix(prefix); + return this; + } + + /** + * Set the suffix that gets appended to view names when building a URL. + * @see UrlBasedViewResolver#setSuffix + */ + public UrlBasedViewResolverRegistration suffix(String suffix) { + this.viewResolver.setSuffix(suffix); + return this; + } + + /** + * Set the view class that should be used to create views. + * @see UrlBasedViewResolver#setViewClass + */ + public UrlBasedViewResolverRegistration viewClass(Class viewClass) { + this.viewResolver.setViewClass(viewClass); + return this; + } + + /** + * Set the view names (or name patterns) that can be handled by this view + * resolver. View names can contain simple wildcards such that 'my*', '*Report' + * and '*Repo*' will all match the view name 'myReport'. + * @see UrlBasedViewResolver#setViewNames + */ + public UrlBasedViewResolverRegistration viewNames(String... viewNames) { + this.viewResolver.setViewNames(viewNames); + return this; + } + + protected UrlBasedViewResolver getViewResolver() { + return this.viewResolver; + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/config/ViewResolverRegistry.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/config/ViewResolverRegistry.java new file mode 100644 index 0000000000..ce5989002d --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/config/ViewResolverRegistry.java @@ -0,0 +1,146 @@ +/* + * Copyright 2002-2016 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.reactive.config; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.springframework.beans.factory.BeanFactoryUtils; +import org.springframework.beans.factory.BeanInitializationException; +import org.springframework.context.ApplicationContext; +import org.springframework.core.Ordered; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; +import org.springframework.web.reactive.result.view.UrlBasedViewResolver; +import org.springframework.web.reactive.result.view.View; +import org.springframework.web.reactive.result.view.ViewResolver; +import org.springframework.web.reactive.result.view.freemarker.FreeMarkerConfigurer; +import org.springframework.web.reactive.result.view.freemarker.FreeMarkerViewResolver; + + +/** + * Assist with the configuration of a chain of {@link ViewResolver}'s supporting + * different template mechanisms. + * + *

In addition, you can also configure {@link #defaultViews(View...) + * defaultViews} for rendering according to the requested content type, e.g. + * JSON, XML, etc. + * + * @author Rossen Stoyanchev + */ +public class ViewResolverRegistry { + + private final List viewResolvers = new ArrayList<>(4); + + private final List defaultViews = new ArrayList<>(4); + + private Integer order; + + private final ApplicationContext applicationContext; + + + public ViewResolverRegistry(ApplicationContext applicationContext) { + Assert.notNull(applicationContext); + this.applicationContext = applicationContext; + } + + + /** + * Register a {@code FreeMarkerViewResolver} with a ".ftl" suffix. + *

Note that you must also configure FreeMarker by + * adding a {@link FreeMarkerConfigurer} bean. + */ + public UrlBasedViewResolverRegistration freeMarker() { + if (this.applicationContext != null && !hasBeanOfType(FreeMarkerConfigurer.class)) { + throw new BeanInitializationException("In addition to a FreeMarker view resolver " + + "there must also be a single FreeMarkerConfig bean in this web application context " + + "(or its parent): FreeMarkerConfigurer is the usual implementation. " + + "This bean may be given any name."); + } + FreeMarkerRegistration registration = new FreeMarkerRegistration(); + UrlBasedViewResolver resolver = registration.getViewResolver(); + resolver.setApplicationContext(this.applicationContext); + this.viewResolvers.add(resolver); + return registration; + } + + protected boolean hasBeanOfType(Class beanType) { + return !ObjectUtils.isEmpty(BeanFactoryUtils.beanNamesForTypeIncludingAncestors( + this.applicationContext, beanType, false, false)); + } + + /** + * Register a {@link ViewResolver} bean instance. This may be useful to + * configure a 3rd party resolver implementation or as an alternative to + * other registration methods in this class when they don't expose some + * more advanced property that needs to be set. + */ + public void viewResolver(ViewResolver viewResolver) { + this.viewResolvers.add(viewResolver); + } + + /** + * Set default views associated with any view name and selected based on the + * best match for the requested content type. + *

Use {@link org.springframework.web.reactive.result.view.HttpMessageConverterView + * HttpMessageConverterView} to adapt and use any existing + * {@code HttpMessageConverter} (e.g. JSON, XML) as a {@code View}. + */ + public void defaultViews(View... defaultViews) { + this.defaultViews.addAll(Arrays.asList(defaultViews)); + } + + /** + * Whether any view resolvers have been registered. + */ + public boolean hasRegistrations() { + return (!this.viewResolvers.isEmpty()); + } + + /** + * Set the order for the + * {@link org.springframework.web.reactive.result.view.ViewResolutionResultHandler + * ViewResolutionResultHandler}. + *

By default this property is not set, which means the result handler is + * ordered at {@link Ordered#LOWEST_PRECEDENCE}. + */ + public void order(int order) { + this.order = order; + } + + protected int getOrder() { + return (this.order != null ? this.order : Ordered.LOWEST_PRECEDENCE); + } + + protected List getViewResolvers() { + return this.viewResolvers; + } + + protected List getDefaultViews() { + return this.defaultViews; + } + + + private static class FreeMarkerRegistration extends UrlBasedViewResolverRegistration { + + public FreeMarkerRegistration() { + super(new FreeMarkerViewResolver()); + getViewResolver().setSuffix(".ftl"); + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/config/WebReactiveConfiguration.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/config/WebReactiveConfiguration.java new file mode 100644 index 0000000000..6382e94e8e --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/config/WebReactiveConfiguration.java @@ -0,0 +1,389 @@ +/* + * Copyright 2002-2016 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.reactive.config; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import reactor.core.converter.Converters; + +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.BeanInitializationException; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.codec.ByteBufferDecoder; +import org.springframework.core.codec.ByteBufferEncoder; +import org.springframework.core.codec.Decoder; +import org.springframework.core.codec.Encoder; +import org.springframework.core.codec.StringDecoder; +import org.springframework.core.codec.StringEncoder; +import org.springframework.core.convert.converter.Converter; +import org.springframework.core.convert.support.MonoToCompletableFutureConverter; +import org.springframework.core.convert.support.ReactorToRxJava1Converter; +import org.springframework.format.Formatter; +import org.springframework.format.FormatterRegistry; +import org.springframework.format.support.DefaultFormattingConversionService; +import org.springframework.format.support.FormattingConversionService; +import org.springframework.http.MediaType; +import org.springframework.http.codec.SseEventEncoder; +import org.springframework.http.codec.json.JacksonJsonDecoder; +import org.springframework.http.codec.json.JacksonJsonEncoder; +import org.springframework.http.codec.xml.Jaxb2Decoder; +import org.springframework.http.codec.xml.Jaxb2Encoder; +import org.springframework.http.converter.reactive.CodecHttpMessageConverter; +import org.springframework.http.converter.reactive.HttpMessageConverter; +import org.springframework.http.converter.reactive.ResourceHttpMessageConverter; +import org.springframework.util.ClassUtils; +import org.springframework.validation.Errors; +import org.springframework.validation.Validator; +import org.springframework.web.reactive.accept.RequestedContentTypeResolver; +import org.springframework.web.reactive.accept.RequestedContentTypeResolverBuilder; +import org.springframework.web.reactive.result.SimpleHandlerAdapter; +import org.springframework.web.reactive.result.SimpleResultHandler; +import org.springframework.web.reactive.result.method.HandlerMethodArgumentResolver; +import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerAdapter; +import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerMapping; +import org.springframework.web.reactive.result.method.annotation.ResponseBodyResultHandler; +import org.springframework.web.reactive.result.method.annotation.ResponseEntityResultHandler; +import org.springframework.web.reactive.result.view.ViewResolutionResultHandler; +import org.springframework.web.reactive.result.view.ViewResolver; + +/** + * The main class for Spring Web Reactive configuration. + * + *

Import directly or extend and override protected methods to customize. + * + * @author Rossen Stoyanchev + */ +@Configuration @SuppressWarnings("unused") +public class WebReactiveConfiguration implements ApplicationContextAware { + + private static final ClassLoader classLoader = WebReactiveConfiguration.class.getClassLoader(); + + private static final boolean jackson2Present = + ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", classLoader) && + ClassUtils.isPresent("com.fasterxml.jackson.core.JsonGenerator", classLoader); + + private static final boolean jaxb2Present = + ClassUtils.isPresent("javax.xml.bind.Binder", classLoader); + + + private PathMatchConfigurer pathMatchConfigurer; + + private List> messageConverters; + + private ApplicationContext applicationContext; + + + @Override + public void setApplicationContext(ApplicationContext applicationContext) { + this.applicationContext = applicationContext; + } + + protected ApplicationContext getApplicationContext() { + return this.applicationContext; + } + + + @Bean + public RequestMappingHandlerMapping requestMappingHandlerMapping() { + RequestMappingHandlerMapping mapping = createRequestMappingHandlerMapping(); + mapping.setOrder(0); + mapping.setContentTypeResolver(mvcContentTypeResolver()); + + PathMatchConfigurer configurer = getPathMatchConfigurer(); + if (configurer.isUseSuffixPatternMatch() != null) { + mapping.setUseSuffixPatternMatch(configurer.isUseSuffixPatternMatch()); + } + if (configurer.isUseRegisteredSuffixPatternMatch() != null) { + mapping.setUseRegisteredSuffixPatternMatch(configurer.isUseRegisteredSuffixPatternMatch()); + } + if (configurer.isUseTrailingSlashMatch() != null) { + mapping.setUseTrailingSlashMatch(configurer.isUseTrailingSlashMatch()); + } + if (configurer.getPathMatcher() != null) { + mapping.setPathMatcher(configurer.getPathMatcher()); + } + if (configurer.getPathHelper() != null) { + mapping.setPathHelper(configurer.getPathHelper()); + } + + return mapping; + } + + /** + * Override to plug a sub-class of {@link RequestMappingHandlerMapping}. + */ + protected RequestMappingHandlerMapping createRequestMappingHandlerMapping() { + return new RequestMappingHandlerMapping(); + } + + @Bean + public RequestedContentTypeResolver mvcContentTypeResolver() { + RequestedContentTypeResolverBuilder builder = new RequestedContentTypeResolverBuilder(); + builder.mediaTypes(getDefaultMediaTypeMappings()); + configureRequestedContentTypeResolver(builder); + return builder.build(); + } + + /** + * Override to configure media type mappings. + * @see RequestedContentTypeResolverBuilder#mediaTypes(Map) + */ + protected Map getDefaultMediaTypeMappings() { + Map map = new HashMap<>(); + if (jackson2Present) { + map.put("json", MediaType.APPLICATION_JSON); + } + return map; + } + + /** + * Override to configure how the requested content type is resolved. + */ + protected void configureRequestedContentTypeResolver(RequestedContentTypeResolverBuilder builder) { + } + + /** + * Callback for building the {@link PathMatchConfigurer}. This method is + * final, use {@link #configurePathMatching} to customize path matching. + */ + protected final PathMatchConfigurer getPathMatchConfigurer() { + if (this.pathMatchConfigurer == null) { + this.pathMatchConfigurer = new PathMatchConfigurer(); + configurePathMatching(this.pathMatchConfigurer); + } + return this.pathMatchConfigurer; + } + + /** + * Override to configure path matching options. + */ + public void configurePathMatching(PathMatchConfigurer configurer) { + } + + @Bean + public RequestMappingHandlerAdapter requestMappingHandlerAdapter() { + RequestMappingHandlerAdapter adapter = createRequestMappingHandlerAdapter(); + + List resolvers = new ArrayList<>(); + addArgumentResolvers(resolvers); + if (!resolvers.isEmpty()) { + adapter.setCustomArgumentResolvers(resolvers); + } + + adapter.setMessageConverters(getMessageConverters()); + adapter.setConversionService(mvcConversionService()); + adapter.setValidator(mvcValidator()); + + return adapter; + } + + /** + * Override to plug a sub-class of {@link RequestMappingHandlerAdapter}. + */ + protected RequestMappingHandlerAdapter createRequestMappingHandlerAdapter() { + return new RequestMappingHandlerAdapter(); + } + + /** + * Provide custom argument resolvers without overriding the built-in ones. + */ + protected void addArgumentResolvers(List resolvers) { + } + + /** + * Main method to access message converters to use for decoding + * controller method arguments and encoding return values. + *

Use {@link #configureMessageConverters} to configure the list or + * {@link #extendMessageConverters} to add in addition to the default ones. + */ + protected final List> getMessageConverters() { + if (this.messageConverters == null) { + this.messageConverters = new ArrayList<>(); + configureMessageConverters(this.messageConverters); + if (this.messageConverters.isEmpty()) { + addDefaultHttpMessageConverters(this.messageConverters); + } + extendMessageConverters(this.messageConverters); + } + return this.messageConverters; + } + + /** + * Override to configure the message converters to use for decoding + * controller method arguments and encoding return values. + *

If no converters are specified, default will be added via + * {@link #addDefaultHttpMessageConverters}. + * @param converters a list to add converters to, initially an empty + */ + protected void configureMessageConverters(List> converters) { + } + + /** + * Adds default converters that sub-classes can call from + * {@link #configureMessageConverters(List)}. + */ + protected final void addDefaultHttpMessageConverters(List> converters) { + List> sseDataEncoders = new ArrayList<>(); + converters.add(converter(new ByteBufferEncoder(), new ByteBufferDecoder())); + converters.add(converter(new StringEncoder(), new StringDecoder())); + converters.add(new ResourceHttpMessageConverter()); + if (jaxb2Present) { + converters.add(converter(new Jaxb2Encoder(), new Jaxb2Decoder())); + } + if (jackson2Present) { + JacksonJsonEncoder jacksonEncoder = new JacksonJsonEncoder(); + JacksonJsonDecoder jacksonDecoder = new JacksonJsonDecoder(); + converters.add(converter(jacksonEncoder, jacksonDecoder)); + sseDataEncoders.add(jacksonEncoder); + } else { + + } + converters.add(converter(new SseEventEncoder(sseDataEncoders), null)); + } + + private static HttpMessageConverter converter(Encoder encoder, Decoder decoder) { + return new CodecHttpMessageConverter<>(encoder, decoder); + } + + /** + * Override this to modify the list of converters after it has been + * configured, for example to add some in addition to the default ones. + */ + protected void extendMessageConverters(List> converters) { + } + + @Bean + public FormattingConversionService mvcConversionService() { + FormattingConversionService service = new DefaultFormattingConversionService(); + addFormatters(service); + return service; + } + + /** + * Override to add custom {@link Converter}s and {@link Formatter}s. + *

By default this method method registers: + *

    + *
  • {@link MonoToCompletableFutureConverter} + *
  • {@link ReactorToRxJava1Converter} + *
+ */ + protected void addFormatters(FormatterRegistry registry) { + registry.addConverter(new MonoToCompletableFutureConverter()); + if (Converters.hasRxJava1()) { + registry.addConverter(new ReactorToRxJava1Converter()); + } + } + + /** + * Return a global {@link Validator} instance for example for validating + * {@code @RequestBody} method arguments. + *

Delegates to {@link #getValidator()} first. If that returns {@code null} + * checks the classpath for the presence of a JSR-303 implementations + * before creating a {@code OptionalValidatorFactoryBean}. If a JSR-303 + * implementation is not available, a "no-op" {@link Validator} is returned. + */ + @Bean + public Validator mvcValidator() { + Validator validator = getValidator(); + if (validator == null) { + if (ClassUtils.isPresent("javax.validation.Validator", getClass().getClassLoader())) { + Class clazz; + try { + String name = "org.springframework.validation.beanvalidation.OptionalValidatorFactoryBean"; + clazz = ClassUtils.forName(name, classLoader); + } + catch (ClassNotFoundException ex) { + throw new BeanInitializationException("Could not find default validator class", ex); + } + catch (LinkageError ex) { + throw new BeanInitializationException("Could not load default validator class", ex); + } + validator = (Validator) BeanUtils.instantiate(clazz); + } + else { + validator = new NoOpValidator(); + } + } + return validator; + } + + /** + * Override this method to provide a custom {@link Validator}. + */ + protected Validator getValidator() { + return null; + } + + @Bean + public SimpleHandlerAdapter simpleHandlerAdapter() { + return new SimpleHandlerAdapter(); + } + + @Bean + public SimpleResultHandler simpleResultHandler() { + return new SimpleResultHandler(mvcConversionService()); + } + + @Bean + public ResponseEntityResultHandler responseEntityResultHandler() { + return new ResponseEntityResultHandler(getMessageConverters(), mvcConversionService(), + mvcContentTypeResolver()); + } + + @Bean + public ResponseBodyResultHandler responseBodyResultHandler() { + return new ResponseBodyResultHandler(getMessageConverters(), mvcConversionService(), + mvcContentTypeResolver()); + } + + @Bean + public ViewResolutionResultHandler viewResolutionResultHandler() { + ViewResolverRegistry registry = new ViewResolverRegistry(this.applicationContext); + configureViewResolvers(registry); + List resolvers = registry.getViewResolvers(); + ViewResolutionResultHandler handler = new ViewResolutionResultHandler(resolvers, mvcConversionService()); + handler.setDefaultViews(registry.getDefaultViews()); + handler.setOrder(registry.getOrder()); + return handler; + + } + + /** + * Override this to configure view resolution. + */ + protected void configureViewResolvers(ViewResolverRegistry registry) { + } + + + private static final class NoOpValidator implements Validator { + + @Override + public boolean supports(Class clazz) { + return false; + } + + @Override + public void validate(Object target, Errors errors) { + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/config/package-info.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/config/package-info.java new file mode 100644 index 0000000000..8ada9a8cd4 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/config/package-info.java @@ -0,0 +1,4 @@ +/** + * Defines Spring Web Reactive configuration. + */ +package org.springframework.web.reactive.config; diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/handler/AbstractHandlerMapping.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/handler/AbstractHandlerMapping.java new file mode 100644 index 0000000000..8cb5267337 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/handler/AbstractHandlerMapping.java @@ -0,0 +1,104 @@ +/* + * Copyright 2002-2016 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.reactive.handler; + +import org.springframework.context.support.ApplicationObjectSupport; +import org.springframework.core.Ordered; +import org.springframework.util.AntPathMatcher; +import org.springframework.util.Assert; +import org.springframework.util.PathMatcher; +import org.springframework.web.reactive.HandlerMapping; +import org.springframework.web.util.HttpRequestPathHelper; + +/** + * Abstract base class for {@link org.springframework.web.reactive.HandlerMapping} + * implementations. + * + * @author Rossen Stoyanchev + */ +public abstract class AbstractHandlerMapping extends ApplicationObjectSupport + implements HandlerMapping, Ordered { + + private int order = Integer.MAX_VALUE; // default: same as non-Ordered + + private HttpRequestPathHelper pathHelper = new HttpRequestPathHelper(); + + private PathMatcher pathMatcher = new AntPathMatcher(); + + + // TODO: CORS + + /** + * Specify the order value for this HandlerMapping bean. + *

Default value is {@code Integer.MAX_VALUE}, meaning that it's non-ordered. + * @see org.springframework.core.Ordered#getOrder() + */ + public final void setOrder(int order) { + this.order = order; + } + + @Override + public final int getOrder() { + return this.order; + } + + /** + * Set if the path should be URL-decoded. This sets the same property on the + * underlying path helper. + * @see HttpRequestPathHelper#setUrlDecode(boolean) + */ + public void setUrlDecode(boolean urlDecode) { + this.pathHelper.setUrlDecode(urlDecode); + } + + /** + * Set the {@link HttpRequestPathHelper} to use for resolution of lookup + * paths. Use this to override the default implementation with a custom + * subclass or to share common path helper settings across multiple + * HandlerMappings. + */ + public void setPathHelper(HttpRequestPathHelper pathHelper) { + this.pathHelper = pathHelper; + } + + /** + * Return the {@link HttpRequestPathHelper} implementation to use for + * resolution of lookup paths. + */ + public HttpRequestPathHelper getPathHelper() { + return this.pathHelper; + } + + /** + * Set the PathMatcher implementation to use for matching URL paths + * against registered URL patterns. Default is AntPathMatcher. + * @see org.springframework.util.AntPathMatcher + */ + public void setPathMatcher(PathMatcher pathMatcher) { + Assert.notNull(pathMatcher, "PathMatcher must not be null"); + this.pathMatcher = pathMatcher; + // this.corsConfigSource.setPathMatcher(pathMatcher); + } + + /** + * Return the PathMatcher implementation to use for matching URL paths + * against registered URL patterns. + */ + public PathMatcher getPathMatcher() { + return this.pathMatcher; + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/handler/AbstractUrlHandlerMapping.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/handler/AbstractUrlHandlerMapping.java new file mode 100644 index 0000000000..1711e43af2 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/handler/AbstractUrlHandlerMapping.java @@ -0,0 +1,257 @@ +/* + * Copyright 2002-2016 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.reactive.handler; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import reactor.core.publisher.Mono; + +import org.springframework.beans.BeansException; +import org.springframework.util.Assert; +import org.springframework.web.server.ServerWebExchange; + +/** + * Abstract base class for URL-mapped + * {@link org.springframework.web.reactive.HandlerMapping} implementations. + * + *

Supports direct matches, e.g. a registered "/test" matches "/test", and + * various Ant-style pattern matches, e.g. a registered "/t*" pattern matches + * both "/test" and "/team", "/test/*" matches all paths under "/test", + * "/test/**" matches all paths below "/test". For details, see the + * {@link org.springframework.util.AntPathMatcher AntPathMatcher} javadoc. + * + *

Will search all path patterns to find the most exact match for the + * current request path. The most exact match is defined as the longest + * path pattern that matches the current request path. + * + * @author Rossen Stoyanchev + */ +public abstract class AbstractUrlHandlerMapping extends AbstractHandlerMapping { + + private boolean useTrailingSlashMatch = false; + + private boolean lazyInitHandlers = false; + + private final Map handlerMap = new LinkedHashMap<>(); + + + /** + * Whether to match to URLs irrespective of the presence of a trailing slash. + * If enabled a URL pattern such as "/users" also matches to "/users/". + *

The default value is {@code false}. + */ + public void setUseTrailingSlashMatch(boolean useTrailingSlashMatch) { + this.useTrailingSlashMatch = useTrailingSlashMatch; + } + + /** + * Whether to match to URLs irrespective of the presence of a trailing slash. + */ + public boolean useTrailingSlashMatch() { + return this.useTrailingSlashMatch; + } + + /** + * Set whether to lazily initialize handlers. Only applicable to + * singleton handlers, as prototypes are always lazily initialized. + * Default is "false", as eager initialization allows for more efficiency + * through referencing the controller objects directly. + *

If you want to allow your controllers to be lazily initialized, + * make them "lazy-init" and set this flag to true. Just making them + * "lazy-init" will not work, as they are initialized through the + * references from the handler mapping in this case. + */ + public void setLazyInitHandlers(boolean lazyInitHandlers) { + this.lazyInitHandlers = lazyInitHandlers; + } + + /** + * Return the registered handlers as an unmodifiable Map, with the registered path + * as key and the handler object (or handler bean name in case of a lazy-init handler) + * as value. + */ + public final Map getHandlerMap() { + return Collections.unmodifiableMap(this.handlerMap); + } + + + @Override + public Mono getHandler(ServerWebExchange exchange) { + String lookupPath = getPathHelper().getLookupPathForRequest(exchange); + Object handler = null; + try { + handler = lookupHandler(lookupPath, exchange); + } + catch (Exception ex) { + return Mono.error(ex); + } + + if (handler != null && logger.isDebugEnabled()) { + logger.debug("Mapping [" + lookupPath + "] to " + handler); + } + else if (handler == null && logger.isTraceEnabled()) { + logger.trace("No handler mapping found for [" + lookupPath + "]"); + } + + return Mono.justOrEmpty(handler); + } + + /** + * Look up a handler instance for the given URL path. + * + *

Supports direct matches, e.g. a registered "/test" matches "/test", + * and various Ant-style pattern matches, e.g. a registered "/t*" matches + * both "/test" and "/team". For details, see the AntPathMatcher class. + * + *

Looks for the most exact pattern, where most exact is defined as + * the longest path pattern. + * + * @param urlPath URL the bean is mapped to + * @param exchange the current exchange + * @return the associated handler instance, or {@code null} if not found + * @see org.springframework.util.AntPathMatcher + */ + protected Object lookupHandler(String urlPath, ServerWebExchange exchange) throws Exception { + // Direct match? + Object handler = this.handlerMap.get(urlPath); + if (handler != null) { + return handleMatch(handler, urlPath, urlPath, exchange); + } + // Pattern match? + List matches = new ArrayList<>(); + for (String pattern : this.handlerMap.keySet()) { + if (getPathMatcher().match(pattern, urlPath)) { + matches.add(pattern); + } + else if (useTrailingSlashMatch()) { + if (!pattern.endsWith("/") && getPathMatcher().match(pattern + "/", urlPath)) { + matches.add(pattern +"/"); + } + } + } + String bestMatch = null; + Comparator comparator = getPathMatcher().getPatternComparator(urlPath); + if (!matches.isEmpty()) { + Collections.sort(matches, comparator); + if (logger.isDebugEnabled()) { + logger.debug("Matching patterns for request [" + urlPath + "] are " + matches); + } + bestMatch = matches.get(0); + } + if (bestMatch != null) { + handler = this.handlerMap.get(bestMatch); + if (handler == null) { + Assert.isTrue(bestMatch.endsWith("/")); + handler = this.handlerMap.get(bestMatch.substring(0, bestMatch.length() - 1)); + } + String pathWithinMapping = getPathMatcher().extractPathWithinPattern(bestMatch, urlPath); + return handleMatch(handler, bestMatch, pathWithinMapping, exchange); + } + // No handler found... + return null; + } + + private Object handleMatch(Object handler, String bestMatch, String pathWithinMapping, + ServerWebExchange exchange) throws Exception { + + // Bean name or resolved handler? + if (handler instanceof String) { + String handlerName = (String) handler; + handler = getApplicationContext().getBean(handlerName); + } + + validateHandler(handler, exchange); + + exchange.getAttributes().put(PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, pathWithinMapping); + exchange.getAttributes().put(BEST_MATCHING_PATTERN_ATTRIBUTE, bestMatch); + + return handler; + } + + /** + * Validate the given handler against the current request. + *

The default implementation is empty. Can be overridden in subclasses, + * for example to enforce specific preconditions expressed in URL mappings. + * @param handler the handler object to validate + * @param exchange current exchange + * @throws Exception if validation failed + */ + @SuppressWarnings("UnusedParameters") + protected void validateHandler(Object handler, ServerWebExchange exchange) throws Exception { + } + + /** + * Register the specified handler for the given URL paths. + * @param urlPaths the URLs that the bean should be mapped to + * @param beanName the name of the handler bean + * @throws BeansException if the handler couldn't be registered + * @throws IllegalStateException if there is a conflicting handler registered + */ + protected void registerHandler(String[] urlPaths, String beanName) throws BeansException, IllegalStateException { + Assert.notNull(urlPaths, "URL path array must not be null"); + for (String urlPath : urlPaths) { + registerHandler(urlPath, beanName); + } + } + + /** + * Register the specified handler for the given URL path. + * @param urlPath the URL the bean should be mapped to + * @param handler the handler instance or handler bean name String + * (a bean name will automatically be resolved into the corresponding handler bean) + * @throws BeansException if the handler couldn't be registered + * @throws IllegalStateException if there is a conflicting handler registered + */ + protected void registerHandler(String urlPath, Object handler) throws BeansException, IllegalStateException { + Assert.notNull(urlPath, "URL path must not be null"); + Assert.notNull(handler, "Handler object must not be null"); + Object resolvedHandler = handler; + + // Eagerly resolve handler if referencing singleton via name. + if (!this.lazyInitHandlers && handler instanceof String) { + String handlerName = (String) handler; + if (getApplicationContext().isSingleton(handlerName)) { + resolvedHandler = getApplicationContext().getBean(handlerName); + } + } + + Object mappedHandler = this.handlerMap.get(urlPath); + if (mappedHandler != null) { + if (mappedHandler != resolvedHandler) { + throw new IllegalStateException( + "Cannot map " + getHandlerDescription(handler) + " to URL path [" + urlPath + + "]: There is already " + getHandlerDescription(mappedHandler) + " mapped."); + } + } + else { + this.handlerMap.put(urlPath, resolvedHandler); + if (logger.isInfoEnabled()) { + logger.info("Mapped URL path [" + urlPath + "] onto " + getHandlerDescription(handler)); + } + } + } + + private String getHandlerDescription(Object handler) { + return "handler " + (handler instanceof String ? "'" + handler + "'" : "of type [" + handler.getClass() + "]"); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/handler/SimpleUrlHandlerMapping.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/handler/SimpleUrlHandlerMapping.java new file mode 100644 index 0000000000..fc96cbe07e --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/handler/SimpleUrlHandlerMapping.java @@ -0,0 +1,131 @@ +/* + * Copyright 2002-2016 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.reactive.handler; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Properties; + +import org.springframework.beans.BeansException; +import org.springframework.util.CollectionUtils; + +/** + * Implementation of the {@link org.springframework.web.reactive.HandlerMapping} + * interface to map from URLs to request handler beans. Supports both mapping + * to bean instances and mapping to bean names; the latter is required for + * non-singleton handlers. + * + *

The "urlMap" property is suitable for populating the handler map with + * bean instances. Mappings to bean names can be set via the "mappings" + * property, in a form accepted by the {@code java.util.Properties} class, + * like as follows: + * + *

+ * /welcome.html=ticketController
+ * /show.html=ticketController
+ * 
+ * + *

The syntax is {@code PATH=HANDLER_BEAN_NAME}. If the path doesn't begin + * with a slash, one is prepended. + * + *

Supports direct matches, e.g. a registered "/test" matches "/test", and + * various Ant-style pattern matches, e.g. a registered "/t*" pattern matches + * both "/test" and "/team", "/test/*" matches all paths under "/test", + * "/test/**" matches all paths below "/test". For details, see the + * {@link org.springframework.util.AntPathMatcher AntPathMatcher} javadoc. + * + * @author Rossen Stoyanchev + */ +public class SimpleUrlHandlerMapping extends AbstractUrlHandlerMapping { + + private final Map urlMap = new LinkedHashMap<>(); + + + /** + * Map URL paths to handler bean names. + * This is the typical way of configuring this HandlerMapping. + *

Supports direct URL matches and Ant-style pattern matches. For syntax + * details, see the {@link org.springframework.util.AntPathMatcher} javadoc. + * @param mappings properties with URLs as keys and bean names as values + * @see #setUrlMap + */ + public void setMappings(Properties mappings) { + CollectionUtils.mergePropertiesIntoMap(mappings, this.urlMap); + } + + /** + * Set a Map with URL paths as keys and handler beans (or handler bean names) + * as values. Convenient for population with bean references. + *

Supports direct URL matches and Ant-style pattern matches. For syntax + * details, see the {@link org.springframework.util.AntPathMatcher} javadoc. + * @param urlMap map with URLs as keys and beans as values + * @see #setMappings + */ + public void setUrlMap(Map urlMap) { + this.urlMap.putAll(urlMap); + } + + /** + * Allow Map access to the URL path mappings, with the option to add or + * override specific entries. + *

Useful for specifying entries directly, for example via "urlMap[myKey]". + * This is particularly useful for adding or overriding entries in child + * bean definitions. + */ + public Map getUrlMap() { + return this.urlMap; + } + + + /** + * Calls the {@link #registerHandlers} method in addition to the + * superclass's initialization. + */ + @Override + public void initApplicationContext() throws BeansException { + super.initApplicationContext(); + registerHandlers(this.urlMap); + } + + /** + * Register all handlers specified in the URL map for the corresponding paths. + * @param urlMap Map with URL paths as keys and handler beans or bean names as values + * @throws BeansException if a handler couldn't be registered + * @throws IllegalStateException if there is a conflicting handler registered + */ + protected void registerHandlers(Map urlMap) throws BeansException { + if (urlMap.isEmpty()) { + logger.warn("Neither 'urlMap' nor 'mappings' set on SimpleUrlHandlerMapping"); + } + else { + for (Map.Entry entry : urlMap.entrySet()) { + String url = entry.getKey(); + Object handler = entry.getValue(); + // Prepend with slash if not already present. + if (!url.startsWith("/")) { + url = "/" + url; + } + // Remove whitespace from handler bean name. + if (handler instanceof String) { + handler = ((String) handler).trim(); + } + registerHandler(url, handler); + } + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/handler/package-info.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/handler/package-info.java new file mode 100644 index 0000000000..7de0c290e4 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/handler/package-info.java @@ -0,0 +1,5 @@ +/** + * Provides standard HandlerMapping implementations, + * including abstract base classes for custom implementations. + */ +package org.springframework.web.reactive.handler; diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/package-info.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/package-info.java new file mode 100644 index 0000000000..92e4457779 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/package-info.java @@ -0,0 +1,4 @@ +/** + * Core interfaces and classes for Spring Web Reactive. + */ +package org.springframework.web.reactive; diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/ContentNegotiatingResultHandlerSupport.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/ContentNegotiatingResultHandlerSupport.java new file mode 100644 index 0000000000..ecf9866a26 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/ContentNegotiatingResultHandlerSupport.java @@ -0,0 +1,148 @@ +/* + * Copyright 2002-2016 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.reactive.result; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import org.springframework.core.Ordered; +import org.springframework.core.convert.ConversionService; +import org.springframework.http.MediaType; +import org.springframework.util.Assert; +import org.springframework.web.reactive.HandlerMapping; +import org.springframework.web.reactive.accept.RequestedContentTypeResolver; +import org.springframework.web.server.ServerWebExchange; + +/** + * Base class for {@link org.springframework.web.reactive.HandlerResultHandler + * HandlerResultHandler} implementations that perform content negotiation. + * + * @author Rossen Stoyanchev + */ +public abstract class ContentNegotiatingResultHandlerSupport implements Ordered { + + private static final MediaType MEDIA_TYPE_APPLICATION_ALL = new MediaType("application"); + + + private final ConversionService conversionService; + + private final RequestedContentTypeResolver contentTypeResolver; + + private int order = LOWEST_PRECEDENCE; + + + protected ContentNegotiatingResultHandlerSupport(ConversionService conversionService, + RequestedContentTypeResolver contentTypeResolver) { + + Assert.notNull(conversionService, "'conversionService' is required."); + Assert.notNull(contentTypeResolver, "'contentTypeResolver' is required."); + this.conversionService = conversionService; + this.contentTypeResolver = contentTypeResolver; + } + + + /** + * Return the configured {@link ConversionService}. + */ + public ConversionService getConversionService() { + return this.conversionService; + } + + /** + * Return the configured {@link RequestedContentTypeResolver}. + */ + public RequestedContentTypeResolver getContentTypeResolver() { + return this.contentTypeResolver; + } + + /** + * Set the order for this result handler relative to others. + *

By default set to {@link Ordered#LOWEST_PRECEDENCE}, however see + * Javadoc of sub-classes which may change this default. + * @param order the order + */ + public void setOrder(int order) { + this.order = order; + } + + @Override + public int getOrder() { + return this.order; + } + + + /** + * Select the best media type for the current request through a content + * negotiation algorithm. + * @param exchange the current request + * @param producibleTypes the media types that can be produced for the current request + * @return the selected media type or {@code null} + */ + protected MediaType selectMediaType(ServerWebExchange exchange, List producibleTypes) { + + List acceptableTypes = getAcceptableTypes(exchange); + producibleTypes = getProducibleTypes(exchange, producibleTypes); + + Set compatibleMediaTypes = new LinkedHashSet<>(); + for (MediaType acceptable : acceptableTypes) { + for (MediaType producible : producibleTypes) { + if (acceptable.isCompatibleWith(producible)) { + compatibleMediaTypes.add(selectMoreSpecificMediaType(acceptable, producible)); + } + } + } + + List result = new ArrayList<>(compatibleMediaTypes); + MediaType.sortBySpecificityAndQuality(result); + + for (MediaType mediaType : compatibleMediaTypes) { + if (mediaType.isConcrete()) { + return mediaType; + } + else if (mediaType.equals(MediaType.ALL) || mediaType.equals(MEDIA_TYPE_APPLICATION_ALL)) { + return MediaType.APPLICATION_OCTET_STREAM; + } + } + + return null; + } + + private List getAcceptableTypes(ServerWebExchange exchange) { + List mediaTypes = getContentTypeResolver().resolveMediaTypes(exchange); + return (mediaTypes.isEmpty() ? Collections.singletonList(MediaType.ALL) : mediaTypes); + } + + private List getProducibleTypes(ServerWebExchange exchange, List mediaTypes) { + Optional optional = exchange.getAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE); + if (optional.isPresent()) { + Set set = (Set) optional.get(); + return new ArrayList<>(set); + } + return mediaTypes; + } + + private MediaType selectMoreSpecificMediaType(MediaType acceptable, MediaType producible) { + producible = producible.copyQualityValue(acceptable); + Comparator comparator = MediaType.SPECIFICITY_COMPARATOR; + return (comparator.compare(acceptable, producible) <= 0 ? acceptable : producible); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/SimpleHandlerAdapter.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/SimpleHandlerAdapter.java new file mode 100644 index 0000000000..0eb5712649 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/SimpleHandlerAdapter.java @@ -0,0 +1,65 @@ +/* + * Copyright 2002-2016 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.reactive.result; + +import java.lang.reflect.Method; + +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.web.reactive.DispatcherHandler; +import org.springframework.web.reactive.HandlerAdapter; +import org.springframework.web.reactive.HandlerResult; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebHandler; + +/** + * HandlerAdapter that allows using the plain {@link WebHandler} contract with + * the generic {@link DispatcherHandler}. + * + * @author Rossen Stoyanchev + * @author Sebastien Deleuze + */ +public class SimpleHandlerAdapter implements HandlerAdapter { + + private static final MethodParameter RETURN_TYPE; + + static { + try { + Method method = WebHandler.class.getMethod("handle", ServerWebExchange.class); + RETURN_TYPE = new MethodParameter(method, -1); + } + catch (NoSuchMethodException ex) { + throw new IllegalStateException( + "Failed to initialize the return type for WebHandler: " + ex.getMessage()); + } + } + + + @Override + public boolean supports(Object handler) { + return WebHandler.class.isAssignableFrom(handler.getClass()); + } + + @Override + public Mono handle(ServerWebExchange exchange, Object handler) { + WebHandler webHandler = (WebHandler) handler; + Mono mono = webHandler.handle(exchange); + return Mono.just(new HandlerResult(webHandler, mono, RETURN_TYPE)); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/SimpleResultHandler.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/SimpleResultHandler.java new file mode 100644 index 0000000000..b3161c86e8 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/SimpleResultHandler.java @@ -0,0 +1,123 @@ +/* + * Copyright 2002-2016 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.reactive.result; + +import java.util.Optional; + +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.Ordered; +import org.springframework.core.ResolvableType; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.util.Assert; +import org.springframework.web.reactive.HandlerResult; +import org.springframework.web.reactive.HandlerResultHandler; +import org.springframework.web.server.ServerWebExchange; + + +/** + * A simple handler for return values of type {@code void}, or + * {@code Publisher}, or if a {link ConversionService} is provided, also + * of any other async return value types that can be converted to + * {@code Publisher} such as {@code Observable} or + * {@code CompletableFuture}. + * + * @author Sebastien Deleuze + * @author Rossen Stoyanchev + */ +public class SimpleResultHandler implements Ordered, HandlerResultHandler { + + protected static final TypeDescriptor MONO_TYPE = TypeDescriptor.valueOf(Mono.class); + + protected static final TypeDescriptor FLUX_TYPE = TypeDescriptor.valueOf(Flux.class); + + + private ConversionService conversionService; + + private int order = Ordered.LOWEST_PRECEDENCE; + + + public SimpleResultHandler(ConversionService conversionService) { + Assert.notNull(conversionService, "'conversionService' is required."); + this.conversionService = conversionService; + } + + + /** + * Return the configured {@link ConversionService}. + */ + public ConversionService getConversionService() { + return this.conversionService; + } + + /** + * Set the order for this result handler relative to others. + *

By default this is set to {@link Ordered#LOWEST_PRECEDENCE} and is + * generally safe to use late in the order since it looks specifically for + * {@code void} or async return types parameterized by {@code void}. + * @param order the order + */ + public void setOrder(int order) { + this.order = order; + } + + @Override + public int getOrder() { + return this.order; + } + + + @Override + public boolean supports(HandlerResult result) { + ResolvableType type = result.getReturnType(); + if (Void.TYPE.equals(type.getRawClass())) { + return true; + } + TypeDescriptor source = new TypeDescriptor(result.getReturnTypeSource()); + if (Publisher.class.isAssignableFrom(type.getRawClass()) || + canConvert(source, MONO_TYPE) || canConvert(source, FLUX_TYPE)) { + Class clazz = result.getReturnType().getGeneric(0).getRawClass(); + return Void.class.equals(clazz); + } + return false; + } + + private boolean canConvert(TypeDescriptor source, TypeDescriptor target) { + return getConversionService().canConvert(source, target); + } + + @SuppressWarnings("unchecked") + @Override + public Mono handleResult(ServerWebExchange exchange, HandlerResult result) { + Optional optional = result.getReturnValue(); + if (!optional.isPresent()) { + return Mono.empty(); + } + Object value = optional.get(); + if (Publisher.class.isAssignableFrom(result.getReturnType().getRawClass())) { + return Mono.from((Publisher) value).then(); + } + TypeDescriptor source = new TypeDescriptor(result.getReturnTypeSource()); + return canConvert(source, MONO_TYPE) ? + ((Mono) getConversionService().convert(value, source, MONO_TYPE)) : + ((Flux) getConversionService().convert(value, source, FLUX_TYPE)).single(); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/AbstractMediaTypeExpression.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/AbstractMediaTypeExpression.java new file mode 100644 index 0000000000..6f9b773574 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/AbstractMediaTypeExpression.java @@ -0,0 +1,120 @@ +/* + * Copyright 2002-2016 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.reactive.result.condition; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.server.NotAcceptableStatusException; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.UnsupportedMediaTypeStatusException; + +/** + * Supports media type expressions as described in: + * {@link RequestMapping#consumes()} and {@link RequestMapping#produces()}. + * + * @author Rossen Stoyanchev + */ +abstract class AbstractMediaTypeExpression implements Comparable, MediaTypeExpression { + + protected final Log logger = LogFactory.getLog(getClass()); + + private final MediaType mediaType; + + private final boolean isNegated; + + + AbstractMediaTypeExpression(String expression) { + if (expression.startsWith("!")) { + this.isNegated = true; + expression = expression.substring(1); + } + else { + this.isNegated = false; + } + this.mediaType = MediaType.parseMediaType(expression); + } + + AbstractMediaTypeExpression(MediaType mediaType, boolean negated) { + this.mediaType = mediaType; + this.isNegated = negated; + } + + + @Override + public MediaType getMediaType() { + return this.mediaType; + } + + @Override + public boolean isNegated() { + return this.isNegated; + } + + + public final boolean match(ServerWebExchange exchange) { + try { + boolean match = matchMediaType(exchange); + return (!this.isNegated == match); + } + catch (NotAcceptableStatusException ex) { + return false; + } + catch (UnsupportedMediaTypeStatusException ex) { + return false; + } + } + + protected abstract boolean matchMediaType(ServerWebExchange exchange) + throws NotAcceptableStatusException, UnsupportedMediaTypeStatusException; + + + @Override + public int compareTo(AbstractMediaTypeExpression other) { + return MediaType.SPECIFICITY_COMPARATOR.compare(this.getMediaType(), other.getMediaType()); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj != null && getClass() == obj.getClass()) { + AbstractMediaTypeExpression other = (AbstractMediaTypeExpression) obj; + return (this.mediaType.equals(other.mediaType) && this.isNegated == other.isNegated); + } + return false; + } + + @Override + public int hashCode() { + return this.mediaType.hashCode(); + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + if (this.isNegated) { + builder.append('!'); + } + builder.append(this.mediaType.toString()); + return builder.toString(); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/AbstractNameValueExpression.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/AbstractNameValueExpression.java new file mode 100644 index 0000000000..9fbbdb2ef7 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/AbstractNameValueExpression.java @@ -0,0 +1,127 @@ +/* + * Copyright 2002-2016 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.reactive.result.condition; + +import org.springframework.web.server.ServerWebExchange; + +/** + * Supports "name=value" style expressions as described in: + * {@link org.springframework.web.bind.annotation.RequestMapping#params()} and + * {@link org.springframework.web.bind.annotation.RequestMapping#headers()}. + * + * @author Rossen Stoyanchev + */ +abstract class AbstractNameValueExpression implements NameValueExpression { + + protected final String name; + + protected final T value; + + protected final boolean isNegated; + + AbstractNameValueExpression(String expression) { + int separator = expression.indexOf('='); + if (separator == -1) { + this.isNegated = expression.startsWith("!"); + this.name = isNegated ? expression.substring(1) : expression; + this.value = null; + } + else { + this.isNegated = (separator > 0) && (expression.charAt(separator - 1) == '!'); + this.name = isNegated ? expression.substring(0, separator - 1) : expression.substring(0, separator); + this.value = parseValue(expression.substring(separator + 1)); + } + } + + @Override + public String getName() { + return this.name; + } + + @Override + public T getValue() { + return this.value; + } + + @Override + public boolean isNegated() { + return this.isNegated; + } + + protected abstract boolean isCaseSensitiveName(); + + protected abstract T parseValue(String valueExpression); + + public final boolean match(ServerWebExchange exchange) { + boolean isMatch; + if (this.value != null) { + isMatch = matchValue(exchange); + } + else { + isMatch = matchName(exchange); + } + return this.isNegated != isMatch; + } + + protected abstract boolean matchName(ServerWebExchange exchange); + + protected abstract boolean matchValue(ServerWebExchange exchange); + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj != null && obj instanceof AbstractNameValueExpression) { + AbstractNameValueExpression other = (AbstractNameValueExpression) obj; + String thisName = isCaseSensitiveName() ? this.name : this.name.toLowerCase(); + String otherName = isCaseSensitiveName() ? other.name : other.name.toLowerCase(); + return ((thisName.equalsIgnoreCase(otherName)) && + (this.value != null ? this.value.equals(other.value) : other.value == null) && + this.isNegated == other.isNegated); + } + return false; + } + + @Override + public int hashCode() { + int result = isCaseSensitiveName() ? name.hashCode() : name.toLowerCase().hashCode(); + result = 31 * result + (value != null ? value.hashCode() : 0); + result = 31 * result + (isNegated ? 1 : 0); + return result; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + if (value != null) { + builder.append(name); + if (isNegated) { + builder.append('!'); + } + builder.append('='); + builder.append(value); + } + else { + if (isNegated) { + builder.append('!'); + } + builder.append(name); + } + return builder.toString(); + } +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/AbstractRequestCondition.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/AbstractRequestCondition.java new file mode 100644 index 0000000000..8b6edb5b68 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/AbstractRequestCondition.java @@ -0,0 +1,86 @@ +/* + * Copyright 2002-2016 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.reactive.result.condition; + +import java.util.Collection; +import java.util.Iterator; + +/** + * A base class for {@link RequestCondition} types providing implementations of + * {@link #equals(Object)}, {@link #hashCode()}, and {@link #toString()}. + * + * @author Rossen Stoyanchev + */ +public abstract class AbstractRequestCondition> + implements RequestCondition { + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj != null && getClass() == obj.getClass()) { + AbstractRequestCondition other = (AbstractRequestCondition) obj; + return getContent().equals(other.getContent()); + } + return false; + } + + @Override + public int hashCode() { + return getContent().hashCode(); + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder("["); + for (Iterator iterator = getContent().iterator(); iterator.hasNext();) { + Object expression = iterator.next(); + builder.append(expression.toString()); + if (iterator.hasNext()) { + builder.append(getToStringInfix()); + } + } + builder.append("]"); + return builder.toString(); + } + + /** + * Indicates whether this condition is empty, i.e. whether or not it + * contains any discrete items. + * @return {@code true} if empty; {@code false} otherwise + */ + public boolean isEmpty() { + return getContent().isEmpty(); + } + + + /** + * Return the discrete items a request condition is composed of. + *

For example URL patterns, HTTP request methods, param expressions, etc. + * @return a collection of objects, never {@code null} + */ + protected abstract Collection getContent(); + + /** + * The notation to use when printing discrete items of content. + *

For example {@code " || "} for URL patterns or {@code " && "} + * for param expressions. + */ + protected abstract String getToStringInfix(); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/CompositeRequestCondition.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/CompositeRequestCondition.java new file mode 100644 index 0000000000..8d70918a55 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/CompositeRequestCondition.java @@ -0,0 +1,184 @@ +/* + * Copyright 2002-2016 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.reactive.result.condition; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; +import org.springframework.web.server.ServerWebExchange; + +/** + * Implements the {@link RequestCondition} contract by delegating to multiple + * {@code RequestCondition} types and using a logical conjunction (' && ') to + * ensure all conditions match a given request. + * + *

When {@code CompositeRequestCondition} instances are combined or compared + * they are expected to (a) contain the same number of conditions and (b) that + * conditions in the respective index are of the same type. It is acceptable to + * provide {@code null} conditions or no conditions at all to the constructor. + * + * @author Rossen Stoyanchev + */ +public class CompositeRequestCondition extends AbstractRequestCondition { + + private final RequestConditionHolder[] requestConditions; + + + /** + * Create an instance with 0 or more {@code RequestCondition} types. It is + * important to create {@code CompositeRequestCondition} instances with the + * same number of conditions so they may be compared and combined. + * It is acceptable to provide {@code null} conditions. + */ + public CompositeRequestCondition(RequestCondition... requestConditions) { + this.requestConditions = wrap(requestConditions); + } + + private CompositeRequestCondition(RequestConditionHolder[] requestConditions) { + this.requestConditions = requestConditions; + } + + + private RequestConditionHolder[] wrap(RequestCondition... rawConditions) { + RequestConditionHolder[] wrappedConditions = new RequestConditionHolder[rawConditions.length]; + for (int i = 0; i < rawConditions.length; i++) { + wrappedConditions[i] = new RequestConditionHolder(rawConditions[i]); + } + return wrappedConditions; + } + + /** + * Whether this instance contains 0 conditions or not. + */ + public boolean isEmpty() { + return ObjectUtils.isEmpty(this.requestConditions); + } + + /** + * Return the underlying conditions, possibly empty but never {@code null}. + */ + public List> getConditions() { + return unwrap(); + } + + private List> unwrap() { + List> result = new ArrayList<>(); + for (RequestConditionHolder holder : this.requestConditions) { + result.add(holder.getCondition()); + } + return result; + } + + @Override + protected Collection getContent() { + return (isEmpty()) ? Collections.emptyList() : getConditions(); + } + + @Override + protected String getToStringInfix() { + return " && "; + } + + private int getLength() { + return this.requestConditions.length; + } + + /** + * If one instance is empty, return the other. + * If both instances have conditions, combine the individual conditions + * after ensuring they are of the same type and number. + */ + @Override + public CompositeRequestCondition combine(CompositeRequestCondition other) { + if (isEmpty() && other.isEmpty()) { + return this; + } + else if (other.isEmpty()) { + return this; + } + else if (isEmpty()) { + return other; + } + else { + assertNumberOfConditions(other); + RequestConditionHolder[] combinedConditions = new RequestConditionHolder[getLength()]; + for (int i = 0; i < getLength(); i++) { + combinedConditions[i] = this.requestConditions[i].combine(other.requestConditions[i]); + } + return new CompositeRequestCondition(combinedConditions); + } + } + + private void assertNumberOfConditions(CompositeRequestCondition other) { + Assert.isTrue(getLength() == other.getLength(), + "Cannot combine CompositeRequestConditions with a different number of conditions. " + + ObjectUtils.nullSafeToString(this.requestConditions) + " and " + + ObjectUtils.nullSafeToString(other.requestConditions)); + } + + /** + * Delegate to all contained conditions to match the request and return the + * resulting "matching" condition instances. + *

An empty {@code CompositeRequestCondition} matches to all requests. + */ + @Override + public CompositeRequestCondition getMatchingCondition(ServerWebExchange exchange) { + if (isEmpty()) { + return this; + } + RequestConditionHolder[] matchingConditions = new RequestConditionHolder[getLength()]; + for (int i = 0; i < getLength(); i++) { + matchingConditions[i] = this.requestConditions[i].getMatchingCondition(exchange); + if (matchingConditions[i] == null) { + return null; + } + } + return new CompositeRequestCondition(matchingConditions); + } + + /** + * If one instance is empty, the other "wins". If both instances have + * conditions, compare them in the order in which they were provided. + */ + @Override + public int compareTo(CompositeRequestCondition other, ServerWebExchange exchange) { + if (isEmpty() && other.isEmpty()) { + return 0; + } + else if (isEmpty()) { + return 1; + } + else if (other.isEmpty()) { + return -1; + } + else { + assertNumberOfConditions(other); + for (int i = 0; i < getLength(); i++) { + int result = this.requestConditions[i].compareTo(other.requestConditions[i], exchange); + if (result != 0) { + return result; + } + } + return 0; + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/ConsumesRequestCondition.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/ConsumesRequestCondition.java new file mode 100644 index 0000000000..7bb7be9890 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/ConsumesRequestCondition.java @@ -0,0 +1,234 @@ +/* + * Copyright 2002-2016 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.reactive.result.condition; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.springframework.http.InvalidMediaTypeException; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.UnsupportedMediaTypeStatusException; + +/** + * A logical disjunction (' || ') request condition to match a request's + * 'Content-Type' header to a list of media type expressions. Two kinds of + * media type expressions are supported, which are described in + * {@link RequestMapping#consumes()} and {@link RequestMapping#headers()} + * where the header name is 'Content-Type'. Regardless of which syntax is + * used, the semantics are the same. + * + * @author Rossen Stoyanchev + */ +public final class ConsumesRequestCondition extends AbstractRequestCondition { + +// private final static ConsumesRequestCondition PRE_FLIGHT_MATCH = new ConsumesRequestCondition(); + + + private final List expressions; + + + /** + * Creates a new instance from 0 or more "consumes" expressions. + * @param consumes expressions with the syntax described in + * {@link RequestMapping#consumes()}; if 0 expressions are provided, + * the condition will match to every request + */ + public ConsumesRequestCondition(String... consumes) { + this(consumes, null); + } + + /** + * Creates a new instance with "consumes" and "header" expressions. + * "Header" expressions where the header name is not 'Content-Type' or have + * no header value defined are ignored. If 0 expressions are provided in + * total, the condition will match to every request + * @param consumes as described in {@link RequestMapping#consumes()} + * @param headers as described in {@link RequestMapping#headers()} + */ + public ConsumesRequestCondition(String[] consumes, String[] headers) { + this(parseExpressions(consumes, headers)); + } + + /** + * Private constructor accepting parsed media type expressions. + */ + private ConsumesRequestCondition(Collection expressions) { + this.expressions = new ArrayList<>(expressions); + Collections.sort(this.expressions); + } + + + private static Set parseExpressions(String[] consumes, String[] headers) { + Set result = new LinkedHashSet<>(); + if (headers != null) { + for (String header : headers) { + HeadersRequestCondition.HeaderExpression expr = new HeadersRequestCondition.HeaderExpression(header); + if ("Content-Type".equalsIgnoreCase(expr.name)) { + for (MediaType mediaType : MediaType.parseMediaTypes(expr.value)) { + result.add(new ConsumeMediaTypeExpression(mediaType, expr.isNegated)); + } + } + } + } + if (consumes != null) { + for (String consume : consumes) { + result.add(new ConsumeMediaTypeExpression(consume)); + } + } + return result; + } + + + /** + * Return the contained MediaType expressions. + */ + public Set getExpressions() { + return new LinkedHashSet<>(this.expressions); + } + + /** + * Returns the media types for this condition excluding negated expressions. + */ + public Set getConsumableMediaTypes() { + Set result = new LinkedHashSet<>(); + for (ConsumeMediaTypeExpression expression : this.expressions) { + if (!expression.isNegated()) { + result.add(expression.getMediaType()); + } + } + return result; + } + + /** + * Whether the condition has any media type expressions. + */ + public boolean isEmpty() { + return this.expressions.isEmpty(); + } + + @Override + protected Collection getContent() { + return this.expressions; + } + + @Override + protected String getToStringInfix() { + return " || "; + } + + /** + * Returns the "other" instance if it has any expressions; returns "this" + * instance otherwise. Practically that means a method-level "consumes" + * overrides a type-level "consumes" condition. + */ + @Override + public ConsumesRequestCondition combine(ConsumesRequestCondition other) { + return !other.expressions.isEmpty() ? other : this; + } + + /** + * Checks if any of the contained media type expressions match the given + * request 'Content-Type' header and returns an instance that is guaranteed + * to contain matching expressions only. The match is performed via + * {@link MediaType#includes(MediaType)}. + * @param exchange the current exchange + * @return the same instance if the condition contains no expressions; + * or a new condition with matching expressions only; + * or {@code null} if no expressions match. + */ + @Override + public ConsumesRequestCondition getMatchingCondition(ServerWebExchange exchange) { +// if (CorsUtils.isPreFlightRequest(request)) { +// return PRE_FLIGHT_MATCH; +// } + if (isEmpty()) { + return this; + } + Set result = new LinkedHashSet<>(expressions); + for (Iterator iterator = result.iterator(); iterator.hasNext();) { + ConsumeMediaTypeExpression expression = iterator.next(); + if (!expression.match(exchange)) { + iterator.remove(); + } + } + return (result.isEmpty()) ? null : new ConsumesRequestCondition(result); + } + + /** + * Returns: + *

    + *
  • 0 if the two conditions have the same number of expressions + *
  • Less than 0 if "this" has more or more specific media type expressions + *
  • Greater than 0 if "other" has more or more specific media type expressions + *
+ *

It is assumed that both instances have been obtained via + * {@link #getMatchingCondition(ServerWebExchange)} and each instance contains + * the matching consumable media type expression only or is otherwise empty. + */ + @Override + public int compareTo(ConsumesRequestCondition other, ServerWebExchange exchange) { + if (this.expressions.isEmpty() && other.expressions.isEmpty()) { + return 0; + } + else if (this.expressions.isEmpty()) { + return 1; + } + else if (other.expressions.isEmpty()) { + return -1; + } + else { + return this.expressions.get(0).compareTo(other.expressions.get(0)); + } + } + + + /** + * Parses and matches a single media type expression to a request's 'Content-Type' header. + */ + static class ConsumeMediaTypeExpression extends AbstractMediaTypeExpression { + + ConsumeMediaTypeExpression(String expression) { + super(expression); + } + + ConsumeMediaTypeExpression(MediaType mediaType, boolean negated) { + super(mediaType, negated); + } + + @Override + protected boolean matchMediaType(ServerWebExchange exchange) throws UnsupportedMediaTypeStatusException { + try { + MediaType contentType = exchange.getRequest().getHeaders().getContentType(); + contentType = (contentType != null ? contentType : MediaType.APPLICATION_OCTET_STREAM); + return getMediaType().includes(contentType); + } + catch (InvalidMediaTypeException ex) { + throw new UnsupportedMediaTypeStatusException("Can't parse Content-Type [" + + exchange.getRequest().getHeaders().getFirst("Content-Type") + + "]: " + ex.getMessage()); + } + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/HeadersRequestCondition.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/HeadersRequestCondition.java new file mode 100644 index 0000000000..14c97a8b41 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/HeadersRequestCondition.java @@ -0,0 +1,167 @@ +/* + * Copyright 2002-2016 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.reactive.result.condition; + +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.server.ServerWebExchange; + +/** + * A logical conjunction (' && ') request condition that matches a request against + * a set of header expressions with syntax defined in {@link RequestMapping#headers()}. + * + *

Expressions passed to the constructor with header names 'Accept' or + * 'Content-Type' are ignored. See {@link ConsumesRequestCondition} and + * {@link ProducesRequestCondition} for those. + * + * @author Rossen Stoyanchev + */ +public final class HeadersRequestCondition extends AbstractRequestCondition { + +// private final static HeadersRequestCondition PRE_FLIGHT_MATCH = new HeadersRequestCondition(); + + + private final Set expressions; + + + /** + * Create a new instance from the given header expressions. Expressions with + * header names 'Accept' or 'Content-Type' are ignored. See {@link ConsumesRequestCondition} + * and {@link ProducesRequestCondition} for those. + * @param headers media type expressions with syntax defined in {@link RequestMapping#headers()}; + * if 0, the condition will match to every request + */ + public HeadersRequestCondition(String... headers) { + this(parseExpressions(headers)); + } + + private HeadersRequestCondition(Collection conditions) { + this.expressions = Collections.unmodifiableSet(new LinkedHashSet<>(conditions)); + } + + + private static Collection parseExpressions(String... headers) { + Set expressions = new LinkedHashSet(); + if (headers != null) { + for (String header : headers) { + HeaderExpression expr = new HeaderExpression(header); + if ("Accept".equalsIgnoreCase(expr.name) || "Content-Type".equalsIgnoreCase(expr.name)) { + continue; + } + expressions.add(expr); + } + } + return expressions; + } + + /** + * Return the contained request header expressions. + */ + public Set> getExpressions() { + return new LinkedHashSet<>(this.expressions); + } + + @Override + protected Collection getContent() { + return this.expressions; + } + + @Override + protected String getToStringInfix() { + return " && "; + } + + /** + * Returns a new instance with the union of the header expressions + * from "this" and the "other" instance. + */ + @Override + public HeadersRequestCondition combine(HeadersRequestCondition other) { + Set set = new LinkedHashSet<>(this.expressions); + set.addAll(other.expressions); + return new HeadersRequestCondition(set); + } + + /** + * Returns "this" instance if the request matches all expressions; + * or {@code null} otherwise. + */ + @Override + public HeadersRequestCondition getMatchingCondition(ServerWebExchange exchange) { +// if (CorsUtils.isPreFlightRequest(request)) { +// return PRE_FLIGHT_MATCH; +// } + for (HeaderExpression expression : expressions) { + if (!expression.match(exchange)) { + return null; + } + } + return this; + } + + /** + * Returns: + *

    + *
  • 0 if the two conditions have the same number of header expressions + *
  • Less than 0 if "this" instance has more header expressions + *
  • Greater than 0 if the "other" instance has more header expressions + *
+ *

It is assumed that both instances have been obtained via + * {@link #getMatchingCondition(ServerWebExchange)} and each instance + * contains the matching header expression only or is otherwise empty. + */ + @Override + public int compareTo(HeadersRequestCondition other, ServerWebExchange exchange) { + return other.expressions.size() - this.expressions.size(); + } + + + /** + * Parses and matches a single header expression to a request. + */ + static class HeaderExpression extends AbstractNameValueExpression { + + public HeaderExpression(String expression) { + super(expression); + } + + @Override + protected boolean isCaseSensitiveName() { + return false; + } + + @Override + protected String parseValue(String valueExpression) { + return valueExpression; + } + + @Override + protected boolean matchName(ServerWebExchange exchange) { + return exchange.getRequest().getHeaders().get(name) != null; + } + + @Override + protected boolean matchValue(ServerWebExchange exchange) { + return value.equals(exchange.getRequest().getHeaders().getFirst(name)); + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/MediaTypeExpression.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/MediaTypeExpression.java new file mode 100644 index 0000000000..23b3a695b9 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/MediaTypeExpression.java @@ -0,0 +1,34 @@ +/* + * Copyright 2002-2016 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.reactive.result.condition; + +import org.springframework.http.MediaType; + +/** + * A contract for media type expressions (e.g. "text/plain", "!text/plain") as + * defined in the {@code @RequestMapping} annotation for "consumes" and + * "produces" conditions. + * + * @author Rossen Stoyanchev + */ +public interface MediaTypeExpression { + + MediaType getMediaType(); + + boolean isNegated(); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/NameValueExpression.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/NameValueExpression.java new file mode 100644 index 0000000000..1f4dd561ae --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/NameValueExpression.java @@ -0,0 +1,33 @@ +/* + * Copyright 2002-2016 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.reactive.result.condition; + +/** + * A contract for {@code "name!=value"} style expression used to specify request + * parameters and request header conditions in {@code @RequestMapping}. + * + * @author Rossen Stoyanchev + */ +public interface NameValueExpression { + + String getName(); + + T getValue(); + + boolean isNegated(); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/ParamsRequestCondition.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/ParamsRequestCondition.java new file mode 100644 index 0000000000..b3829d1a6d --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/ParamsRequestCondition.java @@ -0,0 +1,152 @@ +/* + * Copyright 2002-2016 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.reactive.result.condition; + +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.server.ServerWebExchange; + +/** + * A logical conjunction (' && ') request condition that matches a request against + * a set parameter expressions with syntax defined in {@link RequestMapping#params()}. + * + * @author Rossen Stoyanchev + */ +public final class ParamsRequestCondition extends AbstractRequestCondition { + + private final Set expressions; + + + /** + * Create a new instance from the given param expressions. + * @param params expressions with syntax defined in {@link RequestMapping#params()}; + * if 0, the condition will match to every request. + */ + public ParamsRequestCondition(String... params) { + this(parseExpressions(params)); + } + + private ParamsRequestCondition(Collection conditions) { + this.expressions = Collections.unmodifiableSet(new LinkedHashSet<>(conditions)); + } + + + private static Collection parseExpressions(String... params) { + Set expressions = new LinkedHashSet<>(); + if (params != null) { + for (String param : params) { + expressions.add(new ParamExpression(param)); + } + } + return expressions; + } + + + /** + * Return the contained request parameter expressions. + */ + public Set> getExpressions() { + return new LinkedHashSet<>(this.expressions); + } + + @Override + protected Collection getContent() { + return this.expressions; + } + + @Override + protected String getToStringInfix() { + return " && "; + } + + /** + * Returns a new instance with the union of the param expressions + * from "this" and the "other" instance. + */ + @Override + public ParamsRequestCondition combine(ParamsRequestCondition other) { + Set set = new LinkedHashSet<>(this.expressions); + set.addAll(other.expressions); + return new ParamsRequestCondition(set); + } + + /** + * Returns "this" instance if the request matches all param expressions; + * or {@code null} otherwise. + */ + @Override + public ParamsRequestCondition getMatchingCondition(ServerWebExchange exchange) { + for (ParamExpression expression : expressions) { + if (!expression.match(exchange)) { + return null; + } + } + return this; + } + + /** + * Returns: + *

    + *
  • 0 if the two conditions have the same number of parameter expressions + *
  • Less than 0 if "this" instance has more parameter expressions + *
  • Greater than 0 if the "other" instance has more parameter expressions + *
+ *

It is assumed that both instances have been obtained via + * {@link #getMatchingCondition(ServerWebExchange)} and each instance + * contains the matching parameter expressions only or is otherwise empty. + */ + @Override + public int compareTo(ParamsRequestCondition other, ServerWebExchange exchange) { + return (other.expressions.size() - this.expressions.size()); + } + + + /** + * Parses and matches a single param expression to a request. + */ + static class ParamExpression extends AbstractNameValueExpression { + + ParamExpression(String expression) { + super(expression); + } + + @Override + protected boolean isCaseSensitiveName() { + return true; + } + + @Override + protected String parseValue(String valueExpression) { + return valueExpression; + } + + @Override + protected boolean matchName(ServerWebExchange exchange) { + return exchange.getRequest().getQueryParams().containsKey(this.name); + } + + @Override + protected boolean matchValue(ServerWebExchange exchange) { + return this.value.equals(exchange.getRequest().getQueryParams().getFirst(this.name)); + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/PatternsRequestCondition.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/PatternsRequestCondition.java new file mode 100644 index 0000000000..5c30eafe7c --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/PatternsRequestCondition.java @@ -0,0 +1,286 @@ +/* + * Copyright 2002-2016 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.reactive.result.condition; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.springframework.util.AntPathMatcher; +import org.springframework.util.PathMatcher; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.util.HttpRequestPathHelper; + +/** + * A logical disjunction (' || ') request condition that matches a request + * against a set of URL path patterns. + * + * @author Rossen Stoyanchev + */ +public final class PatternsRequestCondition extends AbstractRequestCondition { + + private final Set patterns; + + private final HttpRequestPathHelper pathHelper; + + private final PathMatcher pathMatcher; + + private final boolean useSuffixPatternMatch; + + private final boolean useTrailingSlashMatch; + + private final Set fileExtensions = new HashSet<>(); + + + /** + * Creates a new instance with the given URL patterns. + * Each pattern that is not empty and does not start with "/" is prepended with "/". + * @param patterns 0 or more URL patterns; if 0 the condition will match to every request. + */ + public PatternsRequestCondition(String... patterns) { + this(asList(patterns), null, null, true, true, null); + } + + /** + * Creates a new instance with the given URL patterns. + * Each pattern that is not empty and does not start with "/" is pre-pended with "/". + * @param patterns the URL patterns to use; if 0, the condition will match to every request. + * @param pathHelper to determine the lookup path for a request + * @param pathMatcher for pattern path matching + * @param useSuffixPatternMatch whether to enable matching by suffix (".*") + * @param useTrailingSlashMatch whether to match irrespective of a trailing slash + * @param extensions file extensions to consider for path matching + */ + public PatternsRequestCondition(String[] patterns, HttpRequestPathHelper pathHelper, + PathMatcher pathMatcher, boolean useSuffixPatternMatch, boolean useTrailingSlashMatch, + Set extensions) { + + this(asList(patterns), pathHelper, pathMatcher, useSuffixPatternMatch, useTrailingSlashMatch, extensions); + } + + /** + * Private constructor accepting a collection of patterns. + */ + private PatternsRequestCondition(Collection patterns, HttpRequestPathHelper pathHelper, + PathMatcher pathMatcher, boolean useSuffixPatternMatch, boolean useTrailingSlashMatch, + Set fileExtensions) { + + this.patterns = Collections.unmodifiableSet(prependLeadingSlash(patterns)); + this.pathHelper = (pathHelper != null ? pathHelper : new HttpRequestPathHelper()); + this.pathMatcher = (pathMatcher != null ? pathMatcher : new AntPathMatcher()); + this.useSuffixPatternMatch = useSuffixPatternMatch; + this.useTrailingSlashMatch = useTrailingSlashMatch; + if (fileExtensions != null) { + for (String fileExtension : fileExtensions) { + if (fileExtension.charAt(0) != '.') { + fileExtension = "." + fileExtension; + } + this.fileExtensions.add(fileExtension); + } + } + } + + + private static List asList(String... patterns) { + return (patterns != null ? Arrays.asList(patterns) : Collections.emptyList()); + } + + private static Set prependLeadingSlash(Collection patterns) { + if (patterns == null) { + return Collections.emptySet(); + } + Set result = new LinkedHashSet<>(patterns.size()); + for (String pattern : patterns) { + if (StringUtils.hasLength(pattern) && !pattern.startsWith("/")) { + pattern = "/" + pattern; + } + result.add(pattern); + } + return result; + } + + public Set getPatterns() { + return this.patterns; + } + + @Override + protected Collection getContent() { + return this.patterns; + } + + @Override + protected String getToStringInfix() { + return " || "; + } + + /** + * Returns a new instance with URL patterns from the current instance ("this") and + * the "other" instance as follows: + *

    + *
  • If there are patterns in both instances, combine the patterns in "this" with + * the patterns in "other" using {@link PathMatcher#combine(String, String)}. + *
  • If only one instance has patterns, use them. + *
  • If neither instance has patterns, use an empty String (i.e. ""). + *
+ */ + @Override + public PatternsRequestCondition combine(PatternsRequestCondition other) { + Set result = new LinkedHashSet<>(); + if (!this.patterns.isEmpty() && !other.patterns.isEmpty()) { + for (String pattern1 : this.patterns) { + for (String pattern2 : other.patterns) { + result.add(this.pathMatcher.combine(pattern1, pattern2)); + } + } + } + else if (!this.patterns.isEmpty()) { + result.addAll(this.patterns); + } + else if (!other.patterns.isEmpty()) { + result.addAll(other.patterns); + } + else { + result.add(""); + } + return new PatternsRequestCondition(result, this.pathHelper, this.pathMatcher, this.useSuffixPatternMatch, + this.useTrailingSlashMatch, this.fileExtensions); + } + + /** + * Checks if any of the patterns match the given request and returns an instance + * that is guaranteed to contain matching patterns, sorted via + * {@link PathMatcher#getPatternComparator(String)}. + *

A matching pattern is obtained by making checks in the following order: + *

    + *
  • Direct match + *
  • Pattern match with ".*" appended if the pattern doesn't already contain a "." + *
  • Pattern match + *
  • Pattern match with "/" appended if the pattern doesn't already end in "/" + *
+ * @param exchange the current exchange + * @return the same instance if the condition contains no patterns; + * or a new condition with sorted matching patterns; + * or {@code null} if no patterns match. + */ + @Override + public PatternsRequestCondition getMatchingCondition(ServerWebExchange exchange) { + if (this.patterns.isEmpty()) { + return this; + } + + String lookupPath = this.pathHelper.getLookupPathForRequest(exchange); + List matches = getMatchingPatterns(lookupPath); + + return matches.isEmpty() ? null : + new PatternsRequestCondition(matches, this.pathHelper, this.pathMatcher, this.useSuffixPatternMatch, + this.useTrailingSlashMatch, this.fileExtensions); + } + + /** + * Find the patterns matching the given lookup path. Invoking this method should + * yield results equivalent to those of calling + * {@link #getMatchingCondition(ServerWebExchange)}. + * This method is provided as an alternative to be used if no request is available + * (e.g. introspection, tooling, etc). + * @param lookupPath the lookup path to match to existing patterns + * @return a collection of matching patterns sorted with the closest match at the top + */ + public List getMatchingPatterns(String lookupPath) { + List matches = new ArrayList<>(); + for (String pattern : this.patterns) { + String match = getMatchingPattern(pattern, lookupPath); + if (match != null) { + matches.add(match); + } + } + Collections.sort(matches, this.pathMatcher.getPatternComparator(lookupPath)); + return matches; + } + + private String getMatchingPattern(String pattern, String lookupPath) { + if (pattern.equals(lookupPath)) { + return pattern; + } + if (this.useSuffixPatternMatch) { + if (!this.fileExtensions.isEmpty() && lookupPath.indexOf('.') != -1) { + for (String extension : this.fileExtensions) { + if (this.pathMatcher.match(pattern + extension, lookupPath)) { + return pattern + extension; + } + } + } + else { + boolean hasSuffix = pattern.indexOf('.') != -1; + if (!hasSuffix && this.pathMatcher.match(pattern + ".*", lookupPath)) { + return pattern + ".*"; + } + } + } + if (this.pathMatcher.match(pattern, lookupPath)) { + return pattern; + } + if (this.useTrailingSlashMatch) { + if (!pattern.endsWith("/") && this.pathMatcher.match(pattern + "/", lookupPath)) { + return pattern +"/"; + } + } + return null; + } + + /** + * Compare the two conditions based on the URL patterns they contain. + * Patterns are compared one at a time, from top to bottom via + * {@link PathMatcher#getPatternComparator(String)}. If all compared + * patterns match equally, but one instance has more patterns, it is + * considered a closer match. + *

It is assumed that both instances have been obtained via + * {@link #getMatchingCondition(ServerWebExchange)} to ensure they + * contain only patterns that match the request and are sorted with + * the best matches on top. + */ + @Override + public int compareTo(PatternsRequestCondition other, ServerWebExchange exchange) { + String lookupPath = this.pathHelper.getLookupPathForRequest(exchange); + Comparator patternComparator = this.pathMatcher.getPatternComparator(lookupPath); + Iterator iterator = this.patterns.iterator(); + Iterator iteratorOther = other.patterns.iterator(); + while (iterator.hasNext() && iteratorOther.hasNext()) { + int result = patternComparator.compare(iterator.next(), iteratorOther.next()); + if (result != 0) { + return result; + } + } + if (iterator.hasNext()) { + return -1; + } + else if (iteratorOther.hasNext()) { + return 1; + } + else { + return 0; + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/ProducesRequestCondition.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/ProducesRequestCondition.java new file mode 100644 index 0000000000..a962687b73 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/ProducesRequestCondition.java @@ -0,0 +1,320 @@ +/* + * Copyright 2002-2016 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.reactive.result.condition; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.springframework.http.MediaType; +import org.springframework.web.accept.ContentNegotiationManager; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.reactive.accept.RequestedContentTypeResolverBuilder; +import org.springframework.web.reactive.accept.RequestedContentTypeResolver; +import org.springframework.web.reactive.accept.HeaderContentTypeResolver; +import org.springframework.web.server.NotAcceptableStatusException; +import org.springframework.web.server.ServerWebExchange; + +/** + * A logical disjunction (' || ') request condition to match a request's 'Accept' header + * to a list of media type expressions. Two kinds of media type expressions are + * supported, which are described in {@link RequestMapping#produces()} and + * {@link RequestMapping#headers()} where the header name is 'Accept'. + * Regardless of which syntax is used, the semantics are the same. + * + * @author Rossen Stoyanchev + */ +public final class ProducesRequestCondition extends AbstractRequestCondition { + +// private final static ProducesRequestCondition PRE_FLIGHT_MATCH = new ProducesRequestCondition(); + + + private final List MEDIA_TYPE_ALL_LIST = + Collections.singletonList(new ProduceMediaTypeExpression("*/*")); + + private final List expressions; + + private final RequestedContentTypeResolver contentTypeResolver; + + + /** + * Creates a new instance from "produces" expressions. If 0 expressions + * are provided in total, this condition will match to any request. + * @param produces expressions with syntax defined by {@link RequestMapping#produces()} + */ + public ProducesRequestCondition(String... produces) { + this(produces, null); + } + + /** + * Creates a new instance with "produces" and "header" expressions. "Header" + * expressions where the header name is not 'Accept' or have no header value + * defined are ignored. If 0 expressions are provided in total, this condition + * will match to any request. + * @param produces expressions with syntax defined by {@link RequestMapping#produces()} + * @param headers expressions with syntax defined by {@link RequestMapping#headers()} + */ + public ProducesRequestCondition(String[] produces, String[] headers) { + this(produces, headers, null); + } + + /** + * Same as {@link #ProducesRequestCondition(String[], String[])} but also + * accepting a {@link ContentNegotiationManager}. + * @param produces expressions with syntax defined by {@link RequestMapping#produces()} + * @param headers expressions with syntax defined by {@link RequestMapping#headers()} + * @param resolver used to determine requested content type + */ + public ProducesRequestCondition(String[] produces, String[] headers, RequestedContentTypeResolver resolver) { + this.expressions = new ArrayList<>(parseExpressions(produces, headers)); + Collections.sort(this.expressions); + this.contentTypeResolver = (resolver != null ? resolver : new HeaderContentTypeResolver()); + } + + /** + * Private constructor with already parsed media type expressions. + */ + private ProducesRequestCondition(Collection expressions, + RequestedContentTypeResolver resolver) { + + this.expressions = new ArrayList<>(expressions); + Collections.sort(this.expressions); + this.contentTypeResolver = (resolver != null ? + resolver : new RequestedContentTypeResolverBuilder().build()); + } + + + private Set parseExpressions(String[] produces, String[] headers) { + Set result = new LinkedHashSet<>(); + if (headers != null) { + for (String header : headers) { + HeadersRequestCondition.HeaderExpression expr = new HeadersRequestCondition.HeaderExpression(header); + if ("Accept".equalsIgnoreCase(expr.name)) { + for (MediaType mediaType : MediaType.parseMediaTypes(expr.value)) { + result.add(new ProduceMediaTypeExpression(mediaType, expr.isNegated)); + } + } + } + } + if (produces != null) { + for (String produce : produces) { + result.add(new ProduceMediaTypeExpression(produce)); + } + } + return result; + } + + /** + * Return the contained "produces" expressions. + */ + public Set getExpressions() { + return new LinkedHashSet<>(this.expressions); + } + + /** + * Return the contained producible media types excluding negated expressions. + */ + public Set getProducibleMediaTypes() { + Set result = new LinkedHashSet<>(); + for (ProduceMediaTypeExpression expression : this.expressions) { + if (!expression.isNegated()) { + result.add(expression.getMediaType()); + } + } + return result; + } + + /** + * Whether the condition has any media type expressions. + */ + public boolean isEmpty() { + return this.expressions.isEmpty(); + } + + @Override + protected List getContent() { + return this.expressions; + } + + @Override + protected String getToStringInfix() { + return " || "; + } + + /** + * Returns the "other" instance if it has any expressions; returns "this" + * instance otherwise. Practically that means a method-level "produces" + * overrides a type-level "produces" condition. + */ + @Override + public ProducesRequestCondition combine(ProducesRequestCondition other) { + return (!other.expressions.isEmpty() ? other : this); + } + + /** + * Checks if any of the contained media type expressions match the given + * request 'Content-Type' header and returns an instance that is guaranteed + * to contain matching expressions only. The match is performed via + * {@link MediaType#isCompatibleWith(MediaType)}. + * @param exchange the current exchange + * @return the same instance if there are no expressions; + * or a new condition with matching expressions; + * or {@code null} if no expressions match. + */ + @Override + public ProducesRequestCondition getMatchingCondition(ServerWebExchange exchange) { +// if (CorsUtils.isPreFlightRequest(request)) { +// return PRE_FLIGHT_MATCH; +// } + if (isEmpty()) { + return this; + } + Set result = new LinkedHashSet<>(expressions); + for (Iterator iterator = result.iterator(); iterator.hasNext();) { + ProduceMediaTypeExpression expression = iterator.next(); + if (!expression.match(exchange)) { + iterator.remove(); + } + } + return (result.isEmpty()) ? null : new ProducesRequestCondition(result, this.contentTypeResolver); + } + + /** + * Compares this and another "produces" condition as follows: + *

    + *
  1. Sort 'Accept' header media types by quality value via + * {@link MediaType#sortByQualityValue(List)} and iterate the list. + *
  2. Get the first index of matching media types in each "produces" + * condition first matching with {@link MediaType#equals(Object)} and + * then with {@link MediaType#includes(MediaType)}. + *
  3. If a lower index is found, the condition at that index wins. + *
  4. If both indexes are equal, the media types at the index are + * compared further with {@link MediaType#SPECIFICITY_COMPARATOR}. + *
+ *

It is assumed that both instances have been obtained via + * {@link #getMatchingCondition(ServerWebExchange)} and each instance + * contains the matching producible media type expression only or + * is otherwise empty. + */ + @Override + public int compareTo(ProducesRequestCondition other, ServerWebExchange exchange) { + try { + List acceptedMediaTypes = getAcceptedMediaTypes(exchange); + for (MediaType acceptedMediaType : acceptedMediaTypes) { + int thisIndex = this.indexOfEqualMediaType(acceptedMediaType); + int otherIndex = other.indexOfEqualMediaType(acceptedMediaType); + int result = compareMatchingMediaTypes(this, thisIndex, other, otherIndex); + if (result != 0) { + return result; + } + thisIndex = this.indexOfIncludedMediaType(acceptedMediaType); + otherIndex = other.indexOfIncludedMediaType(acceptedMediaType); + result = compareMatchingMediaTypes(this, thisIndex, other, otherIndex); + if (result != 0) { + return result; + } + } + return 0; + } + catch (NotAcceptableStatusException ex) { + // should never happen + throw new IllegalStateException("Cannot compare without having any requested media types", ex); + } + } + + private List getAcceptedMediaTypes(ServerWebExchange exchange) + throws NotAcceptableStatusException { + + List mediaTypes = this.contentTypeResolver.resolveMediaTypes(exchange); + return mediaTypes.isEmpty() ? Collections.singletonList(MediaType.ALL) : mediaTypes; + } + + private int indexOfEqualMediaType(MediaType mediaType) { + for (int i = 0; i < getExpressionsToCompare().size(); i++) { + MediaType currentMediaType = getExpressionsToCompare().get(i).getMediaType(); + if (mediaType.getType().equalsIgnoreCase(currentMediaType.getType()) && + mediaType.getSubtype().equalsIgnoreCase(currentMediaType.getSubtype())) { + return i; + } + } + return -1; + } + + private int indexOfIncludedMediaType(MediaType mediaType) { + for (int i = 0; i < getExpressionsToCompare().size(); i++) { + if (mediaType.includes(getExpressionsToCompare().get(i).getMediaType())) { + return i; + } + } + return -1; + } + + private int compareMatchingMediaTypes(ProducesRequestCondition condition1, int index1, + ProducesRequestCondition condition2, int index2) { + + int result = 0; + if (index1 != index2) { + result = index2 - index1; + } + else if (index1 != -1) { + ProduceMediaTypeExpression expr1 = condition1.getExpressionsToCompare().get(index1); + ProduceMediaTypeExpression expr2 = condition2.getExpressionsToCompare().get(index2); + result = expr1.compareTo(expr2); + result = (result != 0) ? result : expr1.getMediaType().compareTo(expr2.getMediaType()); + } + return result; + } + + /** + * Return the contained "produces" expressions or if that's empty, a list + * with a {@code MediaType_ALL} expression. + */ + private List getExpressionsToCompare() { + return (this.expressions.isEmpty() ? MEDIA_TYPE_ALL_LIST : this.expressions); + } + + + /** + * Parses and matches a single media type expression to a request's 'Accept' header. + */ + class ProduceMediaTypeExpression extends AbstractMediaTypeExpression { + + ProduceMediaTypeExpression(MediaType mediaType, boolean negated) { + super(mediaType, negated); + } + + ProduceMediaTypeExpression(String expression) { + super(expression); + } + + @Override + protected boolean matchMediaType(ServerWebExchange exchange) throws NotAcceptableStatusException { + List acceptedMediaTypes = getAcceptedMediaTypes(exchange); + for (MediaType acceptedMediaType : acceptedMediaTypes) { + if (getMediaType().isCompatibleWith(acceptedMediaType)) { + return true; + } + } + return false; + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/RequestCondition.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/RequestCondition.java new file mode 100644 index 0000000000..d21f85829b --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/RequestCondition.java @@ -0,0 +1,66 @@ +/* + * Copyright 2002-2016 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.reactive.result.condition; + +import org.springframework.web.server.ServerWebExchange; + +/** + * Contract for request mapping conditions. + * + *

Request conditions can be combined via {@link #combine(Object)}, matched to + * a request via {@link #getMatchingCondition(ServerWebExchange)}, and compared + * to each other via {@link #compareTo(Object, ServerWebExchange)} to determine + * which is a closer match for a given request. + * + * @author Rossen Stoyanchev + * @param the type of objects that this RequestCondition can be combined + * with and compared to + */ +public interface RequestCondition { + + /** + * Combine this condition with another such as conditions from a + * type-level and method-level {@code @RequestMapping} annotation. + * @param other the condition to combine with. + * @return a request condition instance that is the result of combining + * the two condition instances. + */ + T combine(T other); + + /** + * Check if the condition matches the request returning a potentially new + * instance created for the current request. For example a condition with + * multiple URL patterns may return a new instance only with those patterns + * that match the request. + *

For CORS pre-flight requests, conditions should match to the would-be, + * actual request (e.g. URL pattern, query parameters, and the HTTP method + * from the "Access-Control-Request-Method" header). If a condition cannot + * be matched to a pre-flight request it should return an instance with + * empty content thus not causing a failure to match. + * @return a condition instance in case of a match or {@code null} otherwise. + */ + T getMatchingCondition(ServerWebExchange exchange); + + /** + * Compare this condition to another condition in the context of + * a specific request. This method assumes both instances have + * been obtained via {@link #getMatchingCondition(ServerWebExchange)} + * to ensure they have content relevant to current request only. + */ + int compareTo(T other, ServerWebExchange exchange); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/RequestConditionHolder.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/RequestConditionHolder.java new file mode 100644 index 0000000000..5e035f46a7 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/RequestConditionHolder.java @@ -0,0 +1,139 @@ +/* + * Copyright 2002-2016 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.reactive.result.condition; + +import java.util.Collection; +import java.util.Collections; + +import org.springframework.web.server.ServerWebExchange; + +/** + * A holder for a {@link RequestCondition} useful when the type of the request + * condition is not known ahead of time, e.g. custom condition. Since this + * class is also an implementation of {@code RequestCondition}, effectively it + * decorates the held request condition and allows it to be combined and compared + * with other request conditions in a type and null safe way. + * + *

When two {@code RequestConditionHolder} instances are combined or compared + * with each other, it is expected the conditions they hold are of the same type. + * If they are not, a {@link ClassCastException} is raised. + * + * @author Rossen Stoyanchev + */ +public final class RequestConditionHolder extends AbstractRequestCondition { + + private final RequestCondition condition; + + + /** + * Create a new holder to wrap the given request condition. + * @param requestCondition the condition to hold, may be {@code null} + */ + @SuppressWarnings("unchecked") + public RequestConditionHolder(RequestCondition requestCondition) { + this.condition = (RequestCondition) requestCondition; + } + + + /** + * Return the held request condition, or {@code null} if not holding one. + */ + public RequestCondition getCondition() { + return this.condition; + } + + @Override + protected Collection getContent() { + return (this.condition != null ? Collections.singleton(this.condition) : Collections.emptyList()); + } + + @Override + protected String getToStringInfix() { + return " "; + } + + /** + * Combine the request conditions held by the two RequestConditionHolder + * instances after making sure the conditions are of the same type. + * Or if one holder is empty, the other holder is returned. + */ + @Override + public RequestConditionHolder combine(RequestConditionHolder other) { + if (this.condition == null && other.condition == null) { + return this; + } + else if (this.condition == null) { + return other; + } + else if (other.condition == null) { + return this; + } + else { + assertEqualConditionTypes(other); + RequestCondition combined = (RequestCondition) this.condition.combine(other.condition); + return new RequestConditionHolder(combined); + } + } + + /** + * Ensure the held request conditions are of the same type. + */ + private void assertEqualConditionTypes(RequestConditionHolder other) { + Class clazz = this.condition.getClass(); + Class otherClazz = other.condition.getClass(); + if (!clazz.equals(otherClazz)) { + throw new ClassCastException("Incompatible request conditions: " + clazz + " and " + otherClazz); + } + } + + /** + * Get the matching condition for the held request condition wrap it in a + * new RequestConditionHolder instance. Or otherwise if this is an empty + * holder, return the same holder instance. + */ + @Override + public RequestConditionHolder getMatchingCondition(ServerWebExchange exchange) { + if (this.condition == null) { + return this; + } + RequestCondition match = (RequestCondition) this.condition.getMatchingCondition(exchange); + return (match != null ? new RequestConditionHolder(match) : null); + } + + /** + * Compare the request conditions held by the two RequestConditionHolder + * instances after making sure the conditions are of the same type. + * Or if one holder is empty, the other holder is preferred. + */ + @Override + public int compareTo(RequestConditionHolder other, ServerWebExchange exchange) { + if (this.condition == null && other.condition == null) { + return 0; + } + else if (this.condition == null) { + return 1; + } + else if (other.condition == null) { + return -1; + } + else { + assertEqualConditionTypes(other); + return this.condition.compareTo(other.condition, exchange); + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/RequestMethodsRequestCondition.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/RequestMethodsRequestCondition.java new file mode 100644 index 0000000000..f8d8814da2 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/RequestMethodsRequestCondition.java @@ -0,0 +1,162 @@ +/* + * Copyright 2002-2016 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.reactive.result.condition; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import javax.servlet.http.HttpServletRequest; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.server.ServerWebExchange; + +/** + * A logical disjunction (' || ') request condition that matches a request + * against a set of {@link RequestMethod}s. + * + * @author Rossen Stoyanchev + */ +public final class RequestMethodsRequestCondition extends AbstractRequestCondition { + + private static final RequestMethodsRequestCondition HEAD_CONDITION = + new RequestMethodsRequestCondition(RequestMethod.HEAD); + + + private final Set methods; + + + /** + * Create a new instance with the given request methods. + * @param requestMethods 0 or more HTTP request methods; + * if, 0 the condition will match to every request + */ + public RequestMethodsRequestCondition(RequestMethod... requestMethods) { + this(asList(requestMethods)); + } + + private RequestMethodsRequestCondition(Collection requestMethods) { + this.methods = Collections.unmodifiableSet(new LinkedHashSet<>(requestMethods)); + } + + + private static List asList(RequestMethod... requestMethods) { + return (requestMethods != null ? Arrays.asList(requestMethods) : Collections.emptyList()); + } + + + /** + * Returns all {@link RequestMethod}s contained in this condition. + */ + public Set getMethods() { + return this.methods; + } + + @Override + protected Collection getContent() { + return this.methods; + } + + @Override + protected String getToStringInfix() { + return " || "; + } + + /** + * Returns a new instance with a union of the HTTP request methods + * from "this" and the "other" instance. + */ + @Override + public RequestMethodsRequestCondition combine(RequestMethodsRequestCondition other) { + Set set = new LinkedHashSet<>(this.methods); + set.addAll(other.methods); + return new RequestMethodsRequestCondition(set); + } + + /** + * Check if any of the HTTP request methods match the given request and + * return an instance that contains the matching HTTP request method only. + * @param exchange the current exchange + * @return the same instance if the condition is empty (unless the request + * method is HTTP OPTIONS), a new condition with the matched request method, + * or {@code null} if there is no match or the condition is empty and the + * request method is OPTIONS. + */ + @Override + public RequestMethodsRequestCondition getMatchingCondition(ServerWebExchange exchange) { +// if (CorsUtils.isPreFlightRequest(request)) { +// return matchPreFlight(request); +// } + if (getMethods().isEmpty()) { + if (RequestMethod.OPTIONS.name().equals(exchange.getRequest().getMethod().name())) { + return null; // No implicit match for OPTIONS (we handle it) + } + return this; + } + return matchRequestMethod(exchange.getRequest().getMethod().name()); + } + + /** + * On a pre-flight request match to the would-be, actual request. + * Hence empty conditions is a match, otherwise try to match to the HTTP + * method in the "Access-Control-Request-Method" header. + */ + @SuppressWarnings("unused") + private RequestMethodsRequestCondition matchPreFlight(HttpServletRequest request) { + if (getMethods().isEmpty()) { + return this; + } + String expectedMethod = request.getHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD); + return matchRequestMethod(expectedMethod); + } + + private RequestMethodsRequestCondition matchRequestMethod(String httpMethodValue) { + HttpMethod httpMethod = HttpMethod.resolve(httpMethodValue); + if (httpMethod != null) { + for (RequestMethod method : getMethods()) { + if (httpMethod.matches(method.name())) { + return new RequestMethodsRequestCondition(method); + } + } + if (httpMethod == HttpMethod.HEAD && getMethods().contains(RequestMethod.GET)) { + return HEAD_CONDITION; + } + } + return null; + } + + /** + * Returns: + *
    + *
  • 0 if the two conditions contain the same number of HTTP request methods + *
  • Less than 0 if "this" instance has an HTTP request method but "other" doesn't + *
  • Greater than 0 "other" has an HTTP request method but "this" doesn't + *
+ *

It is assumed that both instances have been obtained via + * {@link #getMatchingCondition(ServerWebExchange)} and therefore each instance + * contains the matching HTTP request method only or is otherwise empty. + */ + @Override + public int compareTo(RequestMethodsRequestCondition other, ServerWebExchange exchange) { + return (other.methods.size() - this.methods.size()); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/package-info.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/package-info.java new file mode 100644 index 0000000000..1163fbdafd --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/condition/package-info.java @@ -0,0 +1,6 @@ +/** + * Support for mapping requests based on a + * {@link org.springframework.web.reactive.result.condition.RequestCondition + * RequestCondition} type hierarchy. + */ +package org.springframework.web.reactive.result.condition; diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/AbstractHandlerMethodMapping.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/AbstractHandlerMethodMapping.java new file mode 100644 index 0000000000..6b23f30ee9 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/AbstractHandlerMethodMapping.java @@ -0,0 +1,562 @@ +/* + * Copyright 2002-2016 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.reactive.result.method; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +import reactor.core.publisher.Mono; + +import org.springframework.aop.support.AopUtils; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.core.MethodIntrospector; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.reactive.HandlerMapping; +import org.springframework.web.reactive.handler.AbstractHandlerMapping; +import org.springframework.web.server.ServerWebExchange; + +/** + * Abstract base class for {@link HandlerMapping} implementations that define + * a mapping between a request and a {@link HandlerMethod}. + * + *

For each registered handler method, a unique mapping is maintained with + * subclasses defining the details of the mapping type {@code }. + * + * @author Rossen Stoyanchev + * @param The mapping for a {@link HandlerMethod} containing the conditions + * needed to match the handler method to incoming request. + */ +public abstract class AbstractHandlerMethodMapping extends AbstractHandlerMapping implements InitializingBean { + + /** + * Bean name prefix for target beans behind scoped proxies. Used to exclude those + * targets from handler method detection, in favor of the corresponding proxies. + *

We're not checking the autowire-candidate status here, which is how the + * proxy target filtering problem is being handled at the autowiring level, + * since autowire-candidate may have been turned to {@code false} for other + * reasons, while still expecting the bean to be eligible for handler methods. + *

Originally defined in {@link org.springframework.aop.scope.ScopedProxyUtils} + * but duplicated here to avoid a hard dependency on the spring-aop module. + */ + private static final String SCOPED_TARGET_NAME_PREFIX = "scopedTarget."; + + + private final MappingRegistry mappingRegistry = new MappingRegistry(); + + + // TODO: handlerMethodMappingNamingStrategy + + /** + * Return a (read-only) map with all mappings and HandlerMethod's. + */ + public Map getHandlerMethods() { + this.mappingRegistry.acquireReadLock(); + try { + return Collections.unmodifiableMap(this.mappingRegistry.getMappings()); + } + finally { + this.mappingRegistry.releaseReadLock(); + } + } + + /** + * Return the internal mapping registry. Provided for testing purposes. + */ + MappingRegistry getMappingRegistry() { + return this.mappingRegistry; + } + + /** + * Register the given mapping. + *

This method may be invoked at runtime after initialization has completed. + * @param mapping the mapping for the handler method + * @param handler the handler + * @param method the method + */ + public void registerMapping(T mapping, Object handler, Method method) { + this.mappingRegistry.register(mapping, handler, method); + } + + /** + * Un-register the given mapping. + *

This method may be invoked at runtime after initialization has completed. + * @param mapping the mapping to unregister + */ + public void unregisterMapping(T mapping) { + this.mappingRegistry.unregister(mapping); + } + + + // Handler method detection + + /** + * Detects handler methods at initialization. + */ + @Override + public void afterPropertiesSet() { + initHandlerMethods(); + } + + /** + * Scan beans in the ApplicationContext, detect and register handler methods. + * @see #isHandler(Class) + * @see #getMappingForMethod(Method, Class) + * @see #handlerMethodsInitialized(Map) + */ + protected void initHandlerMethods() { + if (logger.isDebugEnabled()) { + logger.debug("Looking for request mappings in application context: " + getApplicationContext()); + } + String[] beanNames = getApplicationContext().getBeanNamesForType(Object.class); + + for (String beanName : beanNames) { + if (!beanName.startsWith(SCOPED_TARGET_NAME_PREFIX)) { + Class beanType = null; + try { + beanType = getApplicationContext().getType(beanName); + } + catch (Throwable ex) { + // An unresolvable bean type, probably from a lazy bean - let's ignore it. + if (logger.isDebugEnabled()) { + logger.debug("Could not resolve target class for bean with name '" + beanName + "'", ex); + } + } + if (beanType != null && isHandler(beanType)) { + detectHandlerMethods(beanName); + } + } + } + handlerMethodsInitialized(getHandlerMethods()); + } + + /** + * Look for handler methods in a handler. + * @param handler the bean name of a handler or a handler instance + */ + protected void detectHandlerMethods(final Object handler) { + Class handlerType = (handler instanceof String ? + getApplicationContext().getType((String) handler) : handler.getClass()); + final Class userType = ClassUtils.getUserClass(handlerType); + + Map methods = MethodIntrospector.selectMethods(userType, + (MethodIntrospector.MetadataLookup) method -> getMappingForMethod(method, userType)); + + if (logger.isDebugEnabled()) { + logger.debug(methods.size() + " request handler methods found on " + userType + ": " + methods); + } + for (Map.Entry entry : methods.entrySet()) { + Method invocableMethod = AopUtils.selectInvocableMethod(entry.getKey(), userType); + T mapping = entry.getValue(); + registerHandlerMethod(handler, invocableMethod, mapping); + } + } + + /** + * Register a handler method and its unique mapping. Invoked at startup for + * each detected handler method. + * @param handler the bean name of the handler or the handler instance + * @param method the method to register + * @param mapping the mapping conditions associated with the handler method + * @throws IllegalStateException if another method was already registered + * under the same mapping + */ + protected void registerHandlerMethod(Object handler, Method method, T mapping) { + this.mappingRegistry.register(mapping, handler, method); + } + + /** + * Create the HandlerMethod instance. + * @param handler either a bean name or an actual handler instance + * @param method the target method + * @return the created HandlerMethod + */ + protected HandlerMethod createHandlerMethod(Object handler, Method method) { + HandlerMethod handlerMethod; + if (handler instanceof String) { + String beanName = (String) handler; + handlerMethod = new HandlerMethod(beanName, + getApplicationContext().getAutowireCapableBeanFactory(), method); + } + else { + handlerMethod = new HandlerMethod(handler, method); + } + return handlerMethod; + } + + /** + * Invoked after all handler methods have been detected. + * @param handlerMethods a read-only map with handler methods and mappings. + */ + protected void handlerMethodsInitialized(Map handlerMethods) { + } + + + // Handler method lookup + + /** + * Look up a handler method for the given request. + * @param exchange the current exchange + */ + @Override + public Mono getHandler(ServerWebExchange exchange) { + String lookupPath = getPathHelper().getLookupPathForRequest(exchange); + if (logger.isDebugEnabled()) { + logger.debug("Looking up handler method for path " + lookupPath); + } + this.mappingRegistry.acquireReadLock(); + try { + HandlerMethod handlerMethod = null; + try { + handlerMethod = lookupHandlerMethod(lookupPath, exchange); + } + catch (Exception ex) { + return Mono.error(ex); + } + if (logger.isDebugEnabled()) { + if (handlerMethod != null) { + logger.debug("Returning handler method [" + handlerMethod + "]"); + } + else { + logger.debug("Did not find handler method for [" + lookupPath + "]"); + } + } + return (handlerMethod != null ? Mono.just(handlerMethod.createWithResolvedBean()) : Mono.empty()); + } + finally { + this.mappingRegistry.releaseReadLock(); + } + } + + /** + * Look up the best-matching handler method for the current request. + * If multiple matches are found, the best match is selected. + * @param lookupPath mapping lookup path within the current servlet mapping + * @param exchange the current exchange + * @return the best-matching handler method, or {@code null} if no match + * @see #handleMatch(Object, String, ServerWebExchange) + * @see #handleNoMatch(Set, String, ServerWebExchange) + */ + protected HandlerMethod lookupHandlerMethod(String lookupPath, ServerWebExchange exchange) + throws Exception { + + List matches = new ArrayList(); + List directPathMatches = this.mappingRegistry.getMappingsByUrl(lookupPath); + if (directPathMatches != null) { + addMatchingMappings(directPathMatches, matches, exchange); + } + if (matches.isEmpty()) { + // No choice but to go through all mappings... + addMatchingMappings(this.mappingRegistry.getMappings().keySet(), matches, exchange); + } + + if (!matches.isEmpty()) { + Comparator comparator = new MatchComparator(getMappingComparator(exchange)); + Collections.sort(matches, comparator); + if (logger.isTraceEnabled()) { + logger.trace("Found " + matches.size() + " matching mapping(s) for [" + + lookupPath + "] : " + matches); + } + Match bestMatch = matches.get(0); + if (matches.size() > 1) { + Match secondBestMatch = matches.get(1); + if (comparator.compare(bestMatch, secondBestMatch) == 0) { + Method m1 = bestMatch.handlerMethod.getMethod(); + Method m2 = secondBestMatch.handlerMethod.getMethod(); + throw new IllegalStateException("Ambiguous handler methods mapped for HTTP path '" + + lookupPath + "': {" + m1 + ", " + m2 + "}"); + } + } + handleMatch(bestMatch.mapping, lookupPath, exchange); + return bestMatch.handlerMethod; + } + else { + return handleNoMatch(this.mappingRegistry.getMappings().keySet(), lookupPath, exchange); + } + } + + private void addMatchingMappings(Collection mappings, List matches, ServerWebExchange exchange) { + for (T mapping : mappings) { + T match = getMatchingMapping(mapping, exchange); + if (match != null) { + matches.add(new Match(match, this.mappingRegistry.getMappings().get(mapping))); + } + } + } + + /** + * Invoked when a matching mapping is found. + * @param mapping the matching mapping + * @param lookupPath mapping lookup path within the current servlet mapping + * @param exchange the current exchange + */ + protected void handleMatch(T mapping, String lookupPath, ServerWebExchange exchange) { + } + + /** + * Invoked when no matching mapping is not found. + * @param mappings all registered mappings + * @param lookupPath mapping lookup path within the current servlet mapping + * @param exchange the current exchange + * @return an alternative HandlerMethod or {@code null} + * @throws Exception provides details that can be translated into an error status code + */ + protected HandlerMethod handleNoMatch(Set mappings, String lookupPath, ServerWebExchange exchange) + throws Exception { + + return null; + } + + + // Abstract template methods + + /** + * Whether the given type is a handler with handler methods. + * @param beanType the type of the bean being checked + * @return "true" if this a handler type, "false" otherwise. + */ + protected abstract boolean isHandler(Class beanType); + + /** + * Provide the mapping for a handler method. A method for which no + * mapping can be provided is not a handler method. + * @param method the method to provide a mapping for + * @param handlerType the handler type, possibly a sub-type of the method's + * declaring class + * @return the mapping, or {@code null} if the method is not mapped + */ + protected abstract T getMappingForMethod(Method method, Class handlerType); + + /** + * Extract and return the URL paths contained in a mapping. + */ + protected abstract Set getMappingPathPatterns(T mapping); + + /** + * Check if a mapping matches the current request and return a (potentially + * new) mapping with conditions relevant to the current request. + * @param mapping the mapping to get a match for + * @param exchange the current exchange + * @return the match, or {@code null} if the mapping doesn't match + */ + protected abstract T getMatchingMapping(T mapping, ServerWebExchange exchange); + + /** + * Return a comparator for sorting matching mappings. + * The returned comparator should sort 'better' matches higher. + * @param exchange the current exchange + * @return the comparator (never {@code null}) + */ + protected abstract Comparator getMappingComparator(ServerWebExchange exchange); + + + /** + * A registry that maintains all mappings to handler methods, exposing methods + * to perform lookups and providing concurrent access. + * + *

Package-private for testing purposes. + */ + class MappingRegistry { + + private final Map> registry = new HashMap<>(); + + private final Map mappingLookup = new LinkedHashMap<>(); + + private final MultiValueMap urlLookup = new LinkedMultiValueMap<>(); + + private final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock(); + + /** + * Return all mappings and handler methods. Not thread-safe. + * @see #acquireReadLock() + */ + public Map getMappings() { + return this.mappingLookup; + } + + /** + * Return matches for the given URL path. Not thread-safe. + * @see #acquireReadLock() + */ + public List getMappingsByUrl(String urlPath) { + return this.urlLookup.get(urlPath); + } + + /** + * Acquire the read lock when using getMappings and getMappingsByUrl. + */ + public void acquireReadLock() { + this.readWriteLock.readLock().lock(); + } + + /** + * Release the read lock after using getMappings and getMappingsByUrl. + */ + public void releaseReadLock() { + this.readWriteLock.readLock().unlock(); + } + + public void register(T mapping, Object handler, Method method) { + this.readWriteLock.writeLock().lock(); + try { + HandlerMethod handlerMethod = createHandlerMethod(handler, method); + assertUniqueMethodMapping(handlerMethod, mapping); + + if (logger.isInfoEnabled()) { + logger.info("Mapped \"" + mapping + "\" onto " + handlerMethod); + } + this.mappingLookup.put(mapping, handlerMethod); + + List directUrls = getDirectUrls(mapping); + for (String url : directUrls) { + this.urlLookup.add(url, mapping); + } + + this.registry.put(mapping, new MappingRegistration<>(mapping, handlerMethod, directUrls)); + } + finally { + this.readWriteLock.writeLock().unlock(); + } + } + + private void assertUniqueMethodMapping(HandlerMethod newHandlerMethod, T mapping) { + HandlerMethod handlerMethod = this.mappingLookup.get(mapping); + if (handlerMethod != null && !handlerMethod.equals(newHandlerMethod)) { + throw new IllegalStateException( + "Ambiguous mapping. Cannot map '" + newHandlerMethod.getBean() + "' method \n" + + newHandlerMethod + "\nto " + mapping + ": There is already '" + + handlerMethod.getBean() + "' bean method\n" + handlerMethod + " mapped."); + } + } + + private List getDirectUrls(T mapping) { + List urls = new ArrayList<>(1); + for (String path : getMappingPathPatterns(mapping)) { + if (!getPathMatcher().isPattern(path)) { + urls.add(path); + } + } + return urls; + } + + public void unregister(T mapping) { + this.readWriteLock.writeLock().lock(); + try { + MappingRegistration definition = this.registry.remove(mapping); + if (definition == null) { + return; + } + + this.mappingLookup.remove(definition.getMapping()); + + for (String url : definition.getDirectUrls()) { + List list = this.urlLookup.get(url); + if (list != null) { + list.remove(definition.getMapping()); + if (list.isEmpty()) { + this.urlLookup.remove(url); + } + } + } + } + finally { + this.readWriteLock.writeLock().unlock(); + } + } + } + + + private static class MappingRegistration { + + private final T mapping; + + private final HandlerMethod handlerMethod; + + private final List directUrls; + + + public MappingRegistration(T mapping, HandlerMethod handlerMethod, List directUrls) { + Assert.notNull(mapping); + Assert.notNull(handlerMethod); + this.mapping = mapping; + this.handlerMethod = handlerMethod; + this.directUrls = (directUrls != null ? directUrls : Collections.emptyList()); + } + + public T getMapping() { + return this.mapping; + } + + public HandlerMethod getHandlerMethod() { + return this.handlerMethod; + } + + public List getDirectUrls() { + return this.directUrls; + } + } + + + /** + * A thin wrapper around a matched HandlerMethod and its mapping, for the purpose of + * comparing the best match with a comparator in the context of the current request. + */ + private class Match { + + private final T mapping; + + private final HandlerMethod handlerMethod; + + public Match(T mapping, HandlerMethod handlerMethod) { + this.mapping = mapping; + this.handlerMethod = handlerMethod; + } + + @Override + public String toString() { + return this.mapping.toString(); + } + } + + + private class MatchComparator implements Comparator { + + private final Comparator comparator; + + public MatchComparator(Comparator comparator) { + this.comparator = comparator; + } + + @Override + public int compare(Match match1, Match match2) { + return this.comparator.compare(match1.mapping, match2.mapping); + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/HandlerMethodArgumentResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/HandlerMethodArgumentResolver.java new file mode 100644 index 0000000000..2876124e93 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/HandlerMethodArgumentResolver.java @@ -0,0 +1,44 @@ +/* + * Copyright 2002-2015 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.reactive.result.method; + +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.ui.ModelMap; +import org.springframework.web.server.ServerWebExchange; + + +/** + * @author Rossen Stoyanchev + */ +public interface HandlerMethodArgumentResolver { + + + boolean supportsParameter(MethodParameter parameter); + + /** + * The returned {@link Mono} may produce one or zero values if the argument + * does not resolve to any value, which will result in {@code null} passed + * as the argument value. + * @param parameter the method parameter + * @param model the implicit model for request handling + * @param exchange the current exchange + */ + Mono resolveArgument(MethodParameter parameter, ModelMap model, ServerWebExchange exchange); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/InvocableHandlerMethod.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/InvocableHandlerMethod.java new file mode 100644 index 0000000000..8cdd19af18 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/InvocableHandlerMethod.java @@ -0,0 +1,176 @@ +/* + * Copyright 2002-2015 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.reactive.result.method; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import reactor.core.publisher.Mono; + +import org.springframework.core.DefaultParameterNameDiscoverer; +import org.springframework.core.GenericTypeResolver; +import org.springframework.core.MethodParameter; +import org.springframework.core.ParameterNameDiscoverer; +import org.springframework.ui.ModelMap; +import org.springframework.util.ObjectUtils; +import org.springframework.util.ReflectionUtils; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.reactive.HandlerResult; +import org.springframework.web.server.ServerWebExchange; + + +/** + * @author Rossen Stoyanchev + */ +public class InvocableHandlerMethod extends HandlerMethod { + + private static final Mono NO_ARGS = Mono.just(new Object[0]); + + private final static Object NO_VALUE = new Object(); + + + private List resolvers = new ArrayList<>(); + + private ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer(); + + + public InvocableHandlerMethod(HandlerMethod handlerMethod) { + super(handlerMethod); + } + + public InvocableHandlerMethod(Object bean, Method method) { + super(bean, method); + } + + + public void setHandlerMethodArgumentResolvers(List resolvers) { + this.resolvers.clear(); + this.resolvers.addAll(resolvers); + } + + @Override + protected Method getBridgedMethod() { + return super.getBridgedMethod(); + } + + + /** + * Invoke the method and return a Publisher for the return value. + * @param exchange the current exchange + * @param model the model for request handling + * @param providedArgs optional list of argument values to check by type + * (via {@code instanceof}) for resolving method arguments. + * @return Publisher that produces a single HandlerResult or an error signal; + * never throws an exception + */ + public Mono invokeForRequest(ServerWebExchange exchange, ModelMap model, + Object... providedArgs) { + + return resolveArguments(exchange, model, providedArgs).then(args -> { + try { + Object value = doInvoke(args); + HandlerResult handlerResult = new HandlerResult(this, value, getReturnType(), model); + return Mono.just(handlerResult); + } + catch (InvocationTargetException ex) { + return Mono.error(ex.getTargetException()); + } + catch (Throwable ex) { + String s = getInvocationErrorMessage(args); + return Mono.error(new IllegalStateException(s)); + } + }); + } + + private Mono resolveArguments(ServerWebExchange exchange, ModelMap model, Object... providedArgs) { + if (ObjectUtils.isEmpty(getMethodParameters())) { + return NO_ARGS; + } + try { + List> monos = Stream.of(getMethodParameters()) + .map(param -> { + param.initParameterNameDiscovery(this.parameterNameDiscoverer); + GenericTypeResolver.resolveParameterType(param, getBean().getClass()); + if (!ObjectUtils.isEmpty(providedArgs)) { + for (Object providedArg : providedArgs) { + if (param.getParameterType().isInstance(providedArg)) { + return Mono.just(providedArg).log("reactor.resolved"); + } + } + } + HandlerMethodArgumentResolver resolver = this.resolvers.stream() + .filter(r -> r.supportsParameter(param)) + .findFirst() + .orElseThrow(() -> getArgError("No resolver for ", param, null)); + try { + return resolver.resolveArgument(param, model, exchange) + .defaultIfEmpty(NO_VALUE) + .otherwise(ex -> Mono.error(getArgError("Error resolving ", param, ex))) + .log("reactor.unresolved"); + } + catch (Exception ex) { + throw getArgError("Error resolving ", param, ex); + } + }) + .collect(Collectors.toList()); + + return Mono.when(monos).log("reactor.unresolved").map(args -> + Stream.of(args).map(o -> o != NO_VALUE ? o : null).toArray()); + } + catch (Throwable ex) { + return Mono.error(ex); + } + } + + private IllegalStateException getArgError(String message, MethodParameter param, Throwable cause) { + return new IllegalStateException(message + + "argument [" + param.getParameterIndex() + "] " + + "of type [" + param.getParameterType().getName() + "] " + + "on method [" + getBridgedMethod().toGenericString() + "]", cause); + } + + private Object doInvoke(Object[] args) throws Exception { + if (logger.isTraceEnabled()) { + String target = getBeanType().getSimpleName() + "." + getMethod().getName(); + logger.trace("Invoking [" + target + "] method with arguments " + Arrays.toString(args)); + } + ReflectionUtils.makeAccessible(getBridgedMethod()); + Object returnValue = getBridgedMethod().invoke(getBean(), args); + if (logger.isTraceEnabled()) { + String target = getBeanType().getSimpleName() + "." + getMethod().getName(); + logger.trace("Method [" + target + "] returned [" + returnValue + "]"); + } + return returnValue; + } + + private String getInvocationErrorMessage(Object[] args) { + String argumentDetails = IntStream.range(0, args.length) + .mapToObj(i -> (args[i] != null ? + "[" + i + "][type=" + args[i].getClass().getName() + "][value=" + args[i] + "]" : + "[" + i + "][null]")) + .collect(Collectors.joining(",", " ", " ")); + return "Failed to invoke controller with resolved arguments:" + argumentDetails + + "on method [" + getBridgedMethod().toGenericString() + "]"; + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/RequestMappingInfo.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/RequestMappingInfo.java new file mode 100644 index 0000000000..0d09461d20 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/RequestMappingInfo.java @@ -0,0 +1,602 @@ +/* + * Copyright 2002-2016 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.reactive.result.method; + +import java.util.Set; + +import org.springframework.util.PathMatcher; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.reactive.accept.RequestedContentTypeResolver; +import org.springframework.web.reactive.accept.MappingContentTypeResolver; +import org.springframework.web.reactive.result.condition.ConsumesRequestCondition; +import org.springframework.web.reactive.result.condition.HeadersRequestCondition; +import org.springframework.web.reactive.result.condition.ParamsRequestCondition; +import org.springframework.web.reactive.result.condition.PatternsRequestCondition; +import org.springframework.web.reactive.result.condition.ProducesRequestCondition; +import org.springframework.web.reactive.result.condition.RequestCondition; +import org.springframework.web.reactive.result.condition.RequestConditionHolder; +import org.springframework.web.reactive.result.condition.RequestMethodsRequestCondition; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.util.HttpRequestPathHelper; + +/** + * Encapsulates the following request mapping conditions: + *
    + *
  1. {@link PatternsRequestCondition} + *
  2. {@link RequestMethodsRequestCondition} + *
  3. {@link ParamsRequestCondition} + *
  4. {@link HeadersRequestCondition} + *
  5. {@link ConsumesRequestCondition} + *
  6. {@link ProducesRequestCondition} + *
  7. {@code RequestCondition} (optional, custom request condition) + *
+ * + * @author Rossen Stoyanchev + */ +public final class RequestMappingInfo implements RequestCondition { + + private final String name; + + private final PatternsRequestCondition patternsCondition; + + private final RequestMethodsRequestCondition methodsCondition; + + private final ParamsRequestCondition paramsCondition; + + private final HeadersRequestCondition headersCondition; + + private final ConsumesRequestCondition consumesCondition; + + private final ProducesRequestCondition producesCondition; + + private final RequestConditionHolder customConditionHolder; + + + public RequestMappingInfo(String name, PatternsRequestCondition patterns, RequestMethodsRequestCondition methods, + ParamsRequestCondition params, HeadersRequestCondition headers, ConsumesRequestCondition consumes, + ProducesRequestCondition produces, RequestCondition custom) { + + this.name = (StringUtils.hasText(name) ? name : null); + this.patternsCondition = (patterns != null ? patterns : new PatternsRequestCondition()); + this.methodsCondition = (methods != null ? methods : new RequestMethodsRequestCondition()); + this.paramsCondition = (params != null ? params : new ParamsRequestCondition()); + this.headersCondition = (headers != null ? headers : new HeadersRequestCondition()); + this.consumesCondition = (consumes != null ? consumes : new ConsumesRequestCondition()); + this.producesCondition = (produces != null ? produces : new ProducesRequestCondition()); + this.customConditionHolder = new RequestConditionHolder(custom); + } + + /** + * Creates a new instance with the given request conditions. + */ + public RequestMappingInfo(PatternsRequestCondition patterns, RequestMethodsRequestCondition methods, + ParamsRequestCondition params, HeadersRequestCondition headers, ConsumesRequestCondition consumes, + ProducesRequestCondition produces, RequestCondition custom) { + + this(null, patterns, methods, params, headers, consumes, produces, custom); + } + + /** + * Re-create a RequestMappingInfo with the given custom request condition. + */ + public RequestMappingInfo(RequestMappingInfo info, RequestCondition customRequestCondition) { + this(info.name, info.patternsCondition, info.methodsCondition, info.paramsCondition, info.headersCondition, + info.consumesCondition, info.producesCondition, customRequestCondition); + } + + + /** + * Return the name for this mapping, or {@code null}. + */ + public String getName() { + return this.name; + } + + /** + * Returns the URL patterns of this {@link RequestMappingInfo}; + * or instance with 0 patterns, never {@code null}. + */ + public PatternsRequestCondition getPatternsCondition() { + return this.patternsCondition; + } + + /** + * Returns the HTTP request methods of this {@link RequestMappingInfo}; + * or instance with 0 request methods, never {@code null}. + */ + public RequestMethodsRequestCondition getMethodsCondition() { + return this.methodsCondition; + } + + /** + * Returns the "parameters" condition of this {@link RequestMappingInfo}; + * or instance with 0 parameter expressions, never {@code null}. + */ + public ParamsRequestCondition getParamsCondition() { + return this.paramsCondition; + } + + /** + * Returns the "headers" condition of this {@link RequestMappingInfo}; + * or instance with 0 header expressions, never {@code null}. + */ + public HeadersRequestCondition getHeadersCondition() { + return this.headersCondition; + } + + /** + * Returns the "consumes" condition of this {@link RequestMappingInfo}; + * or instance with 0 consumes expressions, never {@code null}. + */ + public ConsumesRequestCondition getConsumesCondition() { + return this.consumesCondition; + } + + /** + * Returns the "produces" condition of this {@link RequestMappingInfo}; + * or instance with 0 produces expressions, never {@code null}. + */ + public ProducesRequestCondition getProducesCondition() { + return this.producesCondition; + } + + /** + * Returns the "custom" condition of this {@link RequestMappingInfo}; or {@code null}. + */ + public RequestCondition getCustomCondition() { + return this.customConditionHolder.getCondition(); + } + + + /** + * Combines "this" request mapping info (i.e. the current instance) with another request mapping info instance. + *

Example: combine type- and method-level request mappings. + * @return a new request mapping info instance; never {@code null} + */ + @Override + public RequestMappingInfo combine(RequestMappingInfo other) { + String name = combineNames(other); + PatternsRequestCondition patterns = this.patternsCondition.combine(other.patternsCondition); + RequestMethodsRequestCondition methods = this.methodsCondition.combine(other.methodsCondition); + ParamsRequestCondition params = this.paramsCondition.combine(other.paramsCondition); + HeadersRequestCondition headers = this.headersCondition.combine(other.headersCondition); + ConsumesRequestCondition consumes = this.consumesCondition.combine(other.consumesCondition); + ProducesRequestCondition produces = this.producesCondition.combine(other.producesCondition); + RequestConditionHolder custom = this.customConditionHolder.combine(other.customConditionHolder); + + return new RequestMappingInfo(name, patterns, + methods, params, headers, consumes, produces, custom.getCondition()); + } + + private String combineNames(RequestMappingInfo other) { + if (this.name != null && other.name != null) { + String separator = "#"; + return this.name + separator + other.name; + } + else if (this.name != null) { + return this.name; + } + else { + return (other.name != null ? other.name : null); + } + } + + /** + * Checks if all conditions in this request mapping info match the provided request and returns + * a potentially new request mapping info with conditions tailored to the current request. + *

For example the returned instance may contain the subset of URL patterns that match to + * the current request, sorted with best matching patterns on top. + * @return a new instance in case all conditions match; or {@code null} otherwise + */ + @Override + public RequestMappingInfo getMatchingCondition(ServerWebExchange exchange) { + RequestMethodsRequestCondition methods = this.methodsCondition.getMatchingCondition(exchange); + ParamsRequestCondition params = this.paramsCondition.getMatchingCondition(exchange); + HeadersRequestCondition headers = this.headersCondition.getMatchingCondition(exchange); + ConsumesRequestCondition consumes = this.consumesCondition.getMatchingCondition(exchange); + ProducesRequestCondition produces = this.producesCondition.getMatchingCondition(exchange); + + if (methods == null || params == null || headers == null || consumes == null || produces == null) { + return null; + } + + PatternsRequestCondition patterns = this.patternsCondition.getMatchingCondition(exchange); + if (patterns == null) { + return null; + } + + RequestConditionHolder custom = this.customConditionHolder.getMatchingCondition(exchange); + if (custom == null) { + return null; + } + + return new RequestMappingInfo(this.name, patterns, + methods, params, headers, consumes, produces, custom.getCondition()); + } + + /** + * Compares "this" info (i.e. the current instance) with another info in the context of a request. + *

Note: It is assumed both instances have been obtained via + * {@link #getMatchingCondition(ServerWebExchange)} to ensure they have conditions with + * content relevant to current request. + */ + @Override + public int compareTo(RequestMappingInfo other, ServerWebExchange exchange) { + int result = this.patternsCondition.compareTo(other.getPatternsCondition(), exchange); + if (result != 0) { + return result; + } + result = this.paramsCondition.compareTo(other.getParamsCondition(), exchange); + if (result != 0) { + return result; + } + result = this.headersCondition.compareTo(other.getHeadersCondition(), exchange); + if (result != 0) { + return result; + } + result = this.consumesCondition.compareTo(other.getConsumesCondition(), exchange); + if (result != 0) { + return result; + } + result = this.producesCondition.compareTo(other.getProducesCondition(), exchange); + if (result != 0) { + return result; + } + result = this.methodsCondition.compareTo(other.getMethodsCondition(), exchange); + if (result != 0) { + return result; + } + result = this.customConditionHolder.compareTo(other.customConditionHolder, exchange); + if (result != 0) { + return result; + } + return 0; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof RequestMappingInfo)) { + return false; + } + RequestMappingInfo otherInfo = (RequestMappingInfo) other; + return (this.patternsCondition.equals(otherInfo.patternsCondition) && + this.methodsCondition.equals(otherInfo.methodsCondition) && + this.paramsCondition.equals(otherInfo.paramsCondition) && + this.headersCondition.equals(otherInfo.headersCondition) && + this.consumesCondition.equals(otherInfo.consumesCondition) && + this.producesCondition.equals(otherInfo.producesCondition) && + this.customConditionHolder.equals(otherInfo.customConditionHolder)); + } + + @Override + public int hashCode() { + return (this.patternsCondition.hashCode() * 31 + // primary differentiation + this.methodsCondition.hashCode() + this.paramsCondition.hashCode() + + this.headersCondition.hashCode() + this.consumesCondition.hashCode() + + this.producesCondition.hashCode() + this.customConditionHolder.hashCode()); + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder("{"); + builder.append(this.patternsCondition); + if (!this.methodsCondition.isEmpty()) { + builder.append(",methods=").append(this.methodsCondition); + } + if (!this.paramsCondition.isEmpty()) { + builder.append(",params=").append(this.paramsCondition); + } + if (!this.headersCondition.isEmpty()) { + builder.append(",headers=").append(this.headersCondition); + } + if (!this.consumesCondition.isEmpty()) { + builder.append(",consumes=").append(this.consumesCondition); + } + if (!this.producesCondition.isEmpty()) { + builder.append(",produces=").append(this.producesCondition); + } + if (!this.customConditionHolder.isEmpty()) { + builder.append(",custom=").append(this.customConditionHolder); + } + builder.append('}'); + return builder.toString(); + } + + + /** + * Create a new {@code RequestMappingInfo.Builder} with the given paths. + * @param paths the paths to use + */ + public static Builder paths(String... paths) { + return new DefaultBuilder(paths); + } + + + /** + * Defines a builder for creating a RequestMappingInfo. + */ + public interface Builder { + + /** + * Set the path patterns. + */ + Builder paths(String... paths); + + /** + * Set the request method conditions. + */ + Builder methods(RequestMethod... methods); + + /** + * Set the request param conditions. + */ + Builder params(String... params); + + /** + * Set the header conditions. + *

By default this is not set. + */ + Builder headers(String... headers); + + /** + * Set the consumes conditions. + */ + Builder consumes(String... consumes); + + /** + * Set the produces conditions. + */ + Builder produces(String... produces); + + /** + * Set the mapping name. + */ + Builder mappingName(String name); + + /** + * Set a custom condition to use. + */ + Builder customCondition(RequestCondition condition); + + /** + * Provide additional configuration needed for request mapping purposes. + */ + Builder options(BuilderConfiguration options); + + /** + * Build the RequestMappingInfo. + */ + RequestMappingInfo build(); + } + + + private static class DefaultBuilder implements Builder { + + private String[] paths; + + private RequestMethod[] methods; + + private String[] params; + + private String[] headers; + + private String[] consumes; + + private String[] produces; + + private String mappingName; + + private RequestCondition customCondition; + + private BuilderConfiguration options = new BuilderConfiguration(); + + public DefaultBuilder(String... paths) { + this.paths = paths; + } + + @Override + public Builder paths(String... paths) { + this.paths = paths; + return this; + } + + @Override + public DefaultBuilder methods(RequestMethod... methods) { + this.methods = methods; + return this; + } + + @Override + public DefaultBuilder params(String... params) { + this.params = params; + return this; + } + + @Override + public DefaultBuilder headers(String... headers) { + this.headers = headers; + return this; + } + + @Override + public DefaultBuilder consumes(String... consumes) { + this.consumes = consumes; + return this; + } + + @Override + public DefaultBuilder produces(String... produces) { + this.produces = produces; + return this; + } + + @Override + public DefaultBuilder mappingName(String name) { + this.mappingName = name; + return this; + } + + @Override + public DefaultBuilder customCondition(RequestCondition condition) { + this.customCondition = condition; + return this; + } + + @Override + public Builder options(BuilderConfiguration options) { + this.options = options; + return this; + } + + @Override + public RequestMappingInfo build() { + RequestedContentTypeResolver contentTypeResolver = this.options.getContentTypeResolver(); + + PatternsRequestCondition patternsCondition = new PatternsRequestCondition( + this.paths, this.options.getPathHelper(), this.options.getPathMatcher(), + this.options.useSuffixPatternMatch(), this.options.useTrailingSlashMatch(), + this.options.getFileExtensions()); + + return new RequestMappingInfo(this.mappingName, patternsCondition, + new RequestMethodsRequestCondition(methods), + new ParamsRequestCondition(this.params), + new HeadersRequestCondition(this.headers), + new ConsumesRequestCondition(this.consumes, this.headers), + new ProducesRequestCondition(this.produces, this.headers, contentTypeResolver), + this.customCondition); + } + } + + + /** + * Container for configuration options used for request mapping purposes. + * Such configuration is required to create RequestMappingInfo instances but + * is typically used across all RequestMappingInfo instances. + * @see Builder#options + */ + public static class BuilderConfiguration { + + private HttpRequestPathHelper pathHelper; + + private PathMatcher pathMatcher; + + private boolean trailingSlashMatch = true; + + private boolean suffixPatternMatch = true; + + private boolean registeredSuffixPatternMatch = false; + + private RequestedContentTypeResolver contentTypeResolver; + + /** + * Set a custom UrlPathHelper to use for the PatternsRequestCondition. + *

By default this is not set. + */ + public void setPathHelper(HttpRequestPathHelper pathHelper) { + this.pathHelper = pathHelper; + } + + public HttpRequestPathHelper getPathHelper() { + return this.pathHelper; + } + + /** + * Set a custom PathMatcher to use for the PatternsRequestCondition. + *

By default this is not set. + */ + public void setPathMatcher(PathMatcher pathMatcher) { + this.pathMatcher = pathMatcher; + } + + public PathMatcher getPathMatcher() { + return this.pathMatcher; + } + + /** + * Whether to apply trailing slash matching in PatternsRequestCondition. + *

By default this is set to 'true'. + */ + public void setTrailingSlashMatch(boolean trailingSlashMatch) { + this.trailingSlashMatch = trailingSlashMatch; + } + + public boolean useTrailingSlashMatch() { + return this.trailingSlashMatch; + } + + /** + * Whether to apply suffix pattern matching in PatternsRequestCondition. + *

By default this is set to 'true'. + * @see #setRegisteredSuffixPatternMatch(boolean) + */ + public void setSuffixPatternMatch(boolean suffixPatternMatch) { + this.suffixPatternMatch = suffixPatternMatch; + } + + public boolean useSuffixPatternMatch() { + return this.suffixPatternMatch; + } + + /** + * Whether suffix pattern matching should be restricted to registered + * file extensions only. Setting this property also sets + * suffixPatternMatch=true and requires that a + * {@link #setContentTypeResolver} is also configured in order to + * obtain the registered file extensions. + */ + public void setRegisteredSuffixPatternMatch(boolean registeredSuffixPatternMatch) { + this.registeredSuffixPatternMatch = registeredSuffixPatternMatch; + this.suffixPatternMatch = (registeredSuffixPatternMatch || this.suffixPatternMatch); + } + + public boolean useRegisteredSuffixPatternMatch() { + return this.registeredSuffixPatternMatch; + } + + /** + * Return the file extensions to use for suffix pattern matching. If + * {@code registeredSuffixPatternMatch=true}, the extensions are obtained + * from the configured {@code contentTypeResolver}. + */ + public Set getFileExtensions() { + RequestedContentTypeResolver resolver = getContentTypeResolver(); + if (useRegisteredSuffixPatternMatch() && resolver != null) { + if (resolver instanceof MappingContentTypeResolver) { + return ((MappingContentTypeResolver) resolver).getKeys(); + } + } + return null; + } + + /** + * Set the ContentNegotiationManager to use for the ProducesRequestCondition. + *

By default this is not set. + */ + public void setContentTypeResolver(RequestedContentTypeResolver resolver) { + this.contentTypeResolver = resolver; + } + + public RequestedContentTypeResolver getContentTypeResolver() { + return this.contentTypeResolver; + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/RequestMappingInfoHandlerMapping.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/RequestMappingInfoHandlerMapping.java new file mode 100644 index 0000000000..3a9dbd36d8 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/RequestMappingInfoHandlerMapping.java @@ -0,0 +1,445 @@ +/* + * Copyright 2002-2016 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.reactive.result.method; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.StringTokenizer; +import java.util.stream.Collectors; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.InvalidMediaTypeException; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.reactive.HandlerMapping; +import org.springframework.web.reactive.result.condition.NameValueExpression; +import org.springframework.web.server.MethodNotAllowedException; +import org.springframework.web.server.NotAcceptableStatusException; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.ServerWebInputException; +import org.springframework.web.server.UnsupportedMediaTypeStatusException; + +/** + * Abstract base class for classes for which {@link RequestMappingInfo} defines + * the mapping between a request and a handler method. + * + * @author Rossen Stoyanchev + */ +public abstract class RequestMappingInfoHandlerMapping extends AbstractHandlerMethodMapping { + + private static final Method HTTP_OPTIONS_HANDLE_METHOD; + + static { + try { + HTTP_OPTIONS_HANDLE_METHOD = HttpOptionsHandler.class.getMethod("handle"); + } + catch (NoSuchMethodException ex) { + // Should never happen + throw new IllegalStateException("No handler for HTTP OPTIONS", ex); + } + } + + + /** + * Get the URL path patterns associated with this {@link RequestMappingInfo}. + */ + @Override + protected Set getMappingPathPatterns(RequestMappingInfo info) { + return info.getPatternsCondition().getPatterns(); + } + + /** + * Check if the given RequestMappingInfo matches the current request and + * return a (potentially new) instance with conditions that match the + * current request -- for example with a subset of URL patterns. + * @return an info in case of a match; or {@code null} otherwise. + */ + @Override + protected RequestMappingInfo getMatchingMapping(RequestMappingInfo info, ServerWebExchange exchange) { + return info.getMatchingCondition(exchange); + } + + /** + * Provide a Comparator to sort RequestMappingInfos matched to a request. + */ + @Override + protected Comparator getMappingComparator(final ServerWebExchange exchange) { + return (info1, info2) -> info1.compareTo(info2, exchange); + } + + /** + * Expose URI template variables, matrix variables, and producible media types in the request. + * @see HandlerMapping#URI_TEMPLATE_VARIABLES_ATTRIBUTE + * @see HandlerMapping#MATRIX_VARIABLES_ATTRIBUTE + * @see HandlerMapping#PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE + */ + @Override + protected void handleMatch(RequestMappingInfo info, String lookupPath, ServerWebExchange exchange) { + super.handleMatch(info, lookupPath, exchange); + + String bestPattern; + Map uriVariables; + Map decodedUriVariables; + + Set patterns = info.getPatternsCondition().getPatterns(); + if (patterns.isEmpty()) { + bestPattern = lookupPath; + uriVariables = Collections.emptyMap(); + decodedUriVariables = Collections.emptyMap(); + } + else { + bestPattern = patterns.iterator().next(); + uriVariables = getPathMatcher().extractUriTemplateVariables(bestPattern, lookupPath); + decodedUriVariables = getPathHelper().decodePathVariables(exchange, uriVariables); + } + + exchange.getAttributes().put(BEST_MATCHING_PATTERN_ATTRIBUTE, bestPattern); + exchange.getAttributes().put(URI_TEMPLATE_VARIABLES_ATTRIBUTE, decodedUriVariables); + + Map> matrixVars = extractMatrixVariables(exchange, uriVariables); + exchange.getAttributes().put(MATRIX_VARIABLES_ATTRIBUTE, matrixVars); + + if (!info.getProducesCondition().getProducibleMediaTypes().isEmpty()) { + Set mediaTypes = info.getProducesCondition().getProducibleMediaTypes(); + exchange.getAttributes().put(PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE, mediaTypes); + } + } + + private Map> extractMatrixVariables( + ServerWebExchange exchange, Map uriVariables) { + + Map> result = new LinkedHashMap<>(); + for (Entry uriVar : uriVariables.entrySet()) { + String uriVarValue = uriVar.getValue(); + + int equalsIndex = uriVarValue.indexOf('='); + if (equalsIndex == -1) { + continue; + } + + String matrixVariables; + + int semicolonIndex = uriVarValue.indexOf(';'); + if ((semicolonIndex == -1) || (semicolonIndex == 0) || (equalsIndex < semicolonIndex)) { + matrixVariables = uriVarValue; + } + else { + matrixVariables = uriVarValue.substring(semicolonIndex + 1); + uriVariables.put(uriVar.getKey(), uriVarValue.substring(0, semicolonIndex)); + } + + MultiValueMap vars = parseMatrixVariables(matrixVariables); + result.put(uriVar.getKey(), getPathHelper().decodeMatrixVariables(exchange, vars)); + } + return result; + } + + /** + * Parse the given string with matrix variables. An example string would look + * like this {@code "q1=a;q1=b;q2=a,b,c"}. The resulting map would contain + * keys {@code "q1"} and {@code "q2"} with values {@code ["a","b"]} and + * {@code ["a","b","c"]} respectively. + * @param matrixVariables the unparsed matrix variables string + * @return a map with matrix variable names and values (never {@code null}) + */ + private static MultiValueMap parseMatrixVariables(String matrixVariables) { + MultiValueMap result = new LinkedMultiValueMap<>(); + if (!StringUtils.hasText(matrixVariables)) { + return result; + } + StringTokenizer pairs = new StringTokenizer(matrixVariables, ";"); + while (pairs.hasMoreTokens()) { + String pair = pairs.nextToken(); + int index = pair.indexOf('='); + if (index != -1) { + String name = pair.substring(0, index); + String rawValue = pair.substring(index + 1); + for (String value : StringUtils.commaDelimitedListToStringArray(rawValue)) { + result.add(name, value); + } + } + else { + result.add(pair, ""); + } + } + return result; + } + + /** + * Iterate all RequestMappingInfos once again, look if any match by URL at + * least and raise exceptions accordingly. + * @throws MethodNotAllowedException for matches by URL but not by HTTP method + * @throws UnsupportedMediaTypeStatusException if there are matches by URL + * and HTTP method but not by consumable media types + * @throws NotAcceptableStatusException if there are matches by URL and HTTP + * method but not by producible media types + * @throws ServerWebInputException if there are matches by URL and HTTP + * method but not by query parameter conditions + */ + @Override + protected HandlerMethod handleNoMatch(Set infos, String lookupPath, + ServerWebExchange exchange) throws Exception { + + PartialMatchHelper helper = new PartialMatchHelper(infos, exchange); + + if (helper.isEmpty()) { + return null; + } + + ServerHttpRequest request = exchange.getRequest(); + + if (helper.hasMethodsMismatch()) { + HttpMethod httpMethod = request.getMethod(); + Set methods = helper.getAllowedMethods(); + if (HttpMethod.OPTIONS.matches(httpMethod.name())) { + HttpOptionsHandler handler = new HttpOptionsHandler(methods); + return new HandlerMethod(handler, HTTP_OPTIONS_HANDLE_METHOD); + } + throw new MethodNotAllowedException(httpMethod.name(), methods); + } + + if (helper.hasConsumesMismatch()) { + Set mediaTypes = helper.getConsumableMediaTypes(); + MediaType contentType; + try { + contentType = request.getHeaders().getContentType(); + } + catch (InvalidMediaTypeException ex) { + throw new UnsupportedMediaTypeStatusException(ex.getMessage()); + } + throw new UnsupportedMediaTypeStatusException(contentType, new ArrayList<>(mediaTypes)); + } + + if (helper.hasProducesMismatch()) { + Set mediaTypes = helper.getProducibleMediaTypes(); + throw new NotAcceptableStatusException(new ArrayList<>(mediaTypes)); + } + + if (helper.hasParamsMismatch()) { + throw new ServerWebInputException( + "Unsatisfied query parameter conditions: " + helper.getParamConditions() + + ", actual parameters: " + request.getQueryParams()); + } + + return null; + } + + + /** + * Aggregate all partial matches and expose methods checking across them. + */ + private static class PartialMatchHelper { + + private final List partialMatches = new ArrayList<>(); + + + public PartialMatchHelper(Set infos, ServerWebExchange exchange) { + this.partialMatches.addAll(infos.stream(). + filter(info -> info.getPatternsCondition().getMatchingCondition(exchange) != null). + map(info -> new PartialMatch(info, exchange)). + collect(Collectors.toList())); + } + + + /** + * Whether there any partial matches. + */ + public boolean isEmpty() { + return this.partialMatches.isEmpty(); + } + + /** + * Any partial matches for "methods"? + */ + public boolean hasMethodsMismatch() { + return !this.partialMatches.stream(). + filter(PartialMatch::hasMethodsMatch).findAny().isPresent(); + } + + /** + * Any partial matches for "methods" and "consumes"? + */ + public boolean hasConsumesMismatch() { + return !this.partialMatches.stream(). + filter(PartialMatch::hasConsumesMatch).findAny().isPresent(); + } + + /** + * Any partial matches for "methods", "consumes", and "produces"? + */ + public boolean hasProducesMismatch() { + return !this.partialMatches.stream(). + filter(PartialMatch::hasProducesMatch).findAny().isPresent(); + } + + /** + * Any partial matches for "methods", "consumes", "produces", and "params"? + */ + public boolean hasParamsMismatch() { + return !this.partialMatches.stream(). + filter(PartialMatch::hasParamsMatch).findAny().isPresent(); + } + + /** + * Return declared HTTP methods. + */ + public Set getAllowedMethods() { + return this.partialMatches.stream(). + flatMap(m -> m.getInfo().getMethodsCondition().getMethods().stream()). + map(Enum::name). + collect(Collectors.toCollection(LinkedHashSet::new)); + } + + /** + * Return declared "consumable" types but only among those that also + * match the "methods" condition. + */ + public Set getConsumableMediaTypes() { + return this.partialMatches.stream().filter(PartialMatch::hasMethodsMatch). + flatMap(m -> m.getInfo().getConsumesCondition().getConsumableMediaTypes().stream()). + collect(Collectors.toCollection(LinkedHashSet::new)); + } + + /** + * Return declared "producible" types but only among those that also + * match the "methods" and "consumes" conditions. + */ + public Set getProducibleMediaTypes() { + return this.partialMatches.stream().filter(PartialMatch::hasConsumesMatch). + flatMap(m -> m.getInfo().getProducesCondition().getProducibleMediaTypes().stream()). + collect(Collectors.toCollection(LinkedHashSet::new)); + } + + /** + * Return declared "params" conditions but only among those that also + * match the "methods", "consumes", and "params" conditions. + */ + public List>> getParamConditions() { + return this.partialMatches.stream().filter(PartialMatch::hasProducesMatch). + map(match -> match.getInfo().getParamsCondition().getExpressions()). + collect(Collectors.toList()); + } + + + /** + * Container for a RequestMappingInfo that matches the URL path at least. + */ + private static class PartialMatch { + + private final RequestMappingInfo info; + + private final boolean methodsMatch; + + private final boolean consumesMatch; + + private final boolean producesMatch; + + private final boolean paramsMatch; + + + /** + * @param info RequestMappingInfo that matches the URL path + * @param exchange the current exchange + */ + public PartialMatch(RequestMappingInfo info, ServerWebExchange exchange) { + this.info = info; + this.methodsMatch = info.getMethodsCondition().getMatchingCondition(exchange) != null; + this.consumesMatch = info.getConsumesCondition().getMatchingCondition(exchange) != null; + this.producesMatch = info.getProducesCondition().getMatchingCondition(exchange) != null; + this.paramsMatch = info.getParamsCondition().getMatchingCondition(exchange) != null; + } + + + public RequestMappingInfo getInfo() { + return this.info; + } + + public boolean hasMethodsMatch() { + return this.methodsMatch; + } + + public boolean hasConsumesMatch() { + return hasMethodsMatch() && this.consumesMatch; + } + + public boolean hasProducesMatch() { + return hasConsumesMatch() && this.producesMatch; + } + + public boolean hasParamsMatch() { + return hasProducesMatch() && this.paramsMatch; + } + + @Override + public String toString() { + return this.info.toString(); + } + } + } + + /** + * Default handler for HTTP OPTIONS. + */ + private static class HttpOptionsHandler { + + private final HttpHeaders headers = new HttpHeaders(); + + + public HttpOptionsHandler(Set declaredMethods) { + this.headers.setAllow(initAllowedHttpMethods(declaredMethods)); + } + + private static Set initAllowedHttpMethods(Set declaredMethods) { + Set result = new LinkedHashSet<>(declaredMethods.size()); + if (declaredMethods.isEmpty()) { + for (HttpMethod method : HttpMethod.values()) { + if (!HttpMethod.TRACE.equals(method)) { + result.add(method); + } + } + } + else { + boolean hasHead = declaredMethods.contains("HEAD"); + for (String method : declaredMethods) { + result.add(HttpMethod.valueOf(method)); + if (!hasHead && "GET".equals(method)) { + result.add(HttpMethod.HEAD); + } + } + } + return result; + } + + public HttpHeaders handle() { + return this.headers; + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractMessageConverterArgumentResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractMessageConverterArgumentResolver.java new file mode 100644 index 0000000000..5f43652c36 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractMessageConverterArgumentResolver.java @@ -0,0 +1,210 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import java.lang.annotation.Annotation; +import java.util.List; +import java.util.function.Function; +import java.util.stream.Collectors; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.Conventions; +import org.springframework.core.MethodParameter; +import org.springframework.core.ResolvableType; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.http.MediaType; +import org.springframework.http.converter.reactive.HttpMessageConverter; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.Errors; +import org.springframework.validation.SmartValidator; +import org.springframework.validation.Validator; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.ServerWebInputException; +import org.springframework.web.server.UnsupportedMediaTypeStatusException; + +/** + * Abstract base class for argument resolvers that resolve method arguments + * by reading the request body with an {@link HttpMessageConverter}. + * + *

Applies validation if the method argument is annotated with + * {@code @javax.validation.Valid} or + * {@link org.springframework.validation.annotation.Validated}. Validation + * failure results in an {@link ServerWebInputException}. + * + * @author Rossen Stoyanchev + */ +public abstract class AbstractMessageConverterArgumentResolver { + + private static final TypeDescriptor MONO_TYPE = TypeDescriptor.valueOf(Mono.class); + + private static final TypeDescriptor FLUX_TYPE = TypeDescriptor.valueOf(Flux.class); + + + private final List> messageConverters; + + private final ConversionService conversionService; + + private final Validator validator; + + private final List supportedMediaTypes; + + + /** + * Constructor with message converters and a ConversionService. + * @param converters converters for reading the request body with + * @param service for converting to other reactive types from Flux and Mono + * @param validator validator to validate decoded objects with + */ + protected AbstractMessageConverterArgumentResolver(List> converters, + ConversionService service, Validator validator) { + + Assert.notEmpty(converters, "At least one message converter is required."); + Assert.notNull(service, "'conversionService' is required."); + this.messageConverters = converters; + this.conversionService = service; + this.validator = validator; + this.supportedMediaTypes = converters.stream() + .flatMap(converter -> converter.getReadableMediaTypes().stream()) + .collect(Collectors.toList()); + } + + + /** + * Return the configured message converters. + */ + public List> getMessageConverters() { + return this.messageConverters; + } + + /** + * Return the configured {@link ConversionService}. + */ + public ConversionService getConversionService() { + return this.conversionService; + } + + + protected Mono readBody(MethodParameter bodyParameter, boolean isBodyRequired, + ServerWebExchange exchange) { + + TypeDescriptor typeDescriptor = new TypeDescriptor(bodyParameter); + boolean convertFromMono = getConversionService().canConvert(MONO_TYPE, typeDescriptor); + boolean convertFromFlux = getConversionService().canConvert(FLUX_TYPE, typeDescriptor); + + ResolvableType elementType = ResolvableType.forMethodParameter(bodyParameter); + if (convertFromMono || convertFromFlux) { + elementType = elementType.getGeneric(0); + } + + ServerHttpRequest request = exchange.getRequest(); + MediaType mediaType = request.getHeaders().getContentType(); + if (mediaType == null) { + mediaType = MediaType.APPLICATION_OCTET_STREAM; + } + + for (HttpMessageConverter converter : getMessageConverters()) { + if (converter.canRead(elementType, mediaType)) { + if (convertFromFlux) { + Flux flux = converter.read(elementType, request) + .onErrorResumeWith(ex -> Flux.error(getReadError(ex, bodyParameter))); + if (checkRequired(bodyParameter, isBodyRequired)) { + flux = flux.switchIfEmpty(Flux.error(getRequiredBodyError(bodyParameter))); + } + if (this.validator != null) { + flux = flux.map(applyValidationIfApplicable(bodyParameter)); + } + return Mono.just(getConversionService().convert(flux, FLUX_TYPE, typeDescriptor)); + } + else { + Mono mono = converter.readMono(elementType, request) + .otherwise(ex -> Mono.error(getReadError(ex, bodyParameter))); + if (checkRequired(bodyParameter, isBodyRequired)) { + mono = mono.otherwiseIfEmpty(Mono.error(getRequiredBodyError(bodyParameter))); + } + if (this.validator != null) { + mono = mono.map(applyValidationIfApplicable(bodyParameter)); + } + if (convertFromMono) { + return Mono.just(getConversionService().convert(mono, MONO_TYPE, typeDescriptor)); + } + else { + return Mono.from(mono); + } + } + } + } + + return Mono.error(new UnsupportedMediaTypeStatusException(mediaType, this.supportedMediaTypes)); + } + + protected boolean checkRequired(MethodParameter bodyParameter, boolean isBodyRequired) { + if ("rx.Single".equals(bodyParameter.getNestedParameterType().getName())) { + return true; + } + return isBodyRequired; + } + + protected ServerWebInputException getReadError(Throwable ex, MethodParameter parameter) { + return new ServerWebInputException("Failed to read HTTP message", parameter, ex); + } + + protected ServerWebInputException getRequiredBodyError(MethodParameter parameter) { + return new ServerWebInputException("Required request body is missing: " + + parameter.getMethod().toGenericString()); + } + + protected Function applyValidationIfApplicable(MethodParameter methodParam) { + Annotation[] annotations = methodParam.getParameterAnnotations(); + for (Annotation ann : annotations) { + Validated validAnnot = AnnotationUtils.getAnnotation(ann, Validated.class); + if (validAnnot != null || ann.annotationType().getSimpleName().startsWith("Valid")) { + Object hints = (validAnnot != null ? validAnnot.value() : AnnotationUtils.getValue(ann)); + Object[] validHints = (hints instanceof Object[] ? (Object[]) hints : new Object[] {hints}); + return element -> { + doValidate(element, validHints, methodParam); + return element; + }; + } + } + return element -> element; + } + + /** + * TODO: replace with use of DataBinder + */ + private void doValidate(Object target, Object[] validationHints, MethodParameter methodParam) { + String name = Conventions.getVariableNameForParameter(methodParam); + Errors errors = new BeanPropertyBindingResult(target, name); + if (!ObjectUtils.isEmpty(validationHints) && this.validator instanceof SmartValidator) { + ((SmartValidator) this.validator).validate(target, errors, validationHints); + } + else if (this.validator != null) { + this.validator.validate(target, errors); + } + if (errors.hasErrors()) { + throw new ServerWebInputException("Validation failed", methodParam); + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractMessageConverterResultHandler.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractMessageConverterResultHandler.java new file mode 100644 index 0000000000..95f5ca57b4 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractMessageConverterResultHandler.java @@ -0,0 +1,138 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import java.util.List; +import java.util.stream.Collectors; + +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.core.ResolvableType; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.http.MediaType; +import org.springframework.http.converter.reactive.HttpMessageConverter; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.util.Assert; +import org.springframework.web.reactive.accept.RequestedContentTypeResolver; +import org.springframework.web.reactive.result.ContentNegotiatingResultHandlerSupport; +import org.springframework.web.server.NotAcceptableStatusException; +import org.springframework.web.server.ServerWebExchange; + +/** + * Abstract base class for result handlers that handle return values by writing + * to the response with {@link HttpMessageConverter}. + * + * @author Rossen Stoyanchev + */ +public abstract class AbstractMessageConverterResultHandler extends ContentNegotiatingResultHandlerSupport { + + protected static final TypeDescriptor MONO_TYPE = TypeDescriptor.valueOf(Mono.class); + + protected static final TypeDescriptor FLUX_TYPE = TypeDescriptor.valueOf(Flux.class); + + + private final List> messageConverters; + + + /** + * Constructor with message converters, a {@code ConversionService}, and a + * {@code RequestedContentTypeResolver}. + * + * @param converters converters for writing the response body with + * @param conversionService for converting other reactive types (e.g. + * rx.Observable, rx.Single, etc.) to Flux or Mono + * @param contentTypeResolver for resolving the requested content type + */ + protected AbstractMessageConverterResultHandler(List> converters, + ConversionService conversionService, RequestedContentTypeResolver contentTypeResolver) { + + super(conversionService, contentTypeResolver); + Assert.notEmpty(converters, "At least one message converter is required."); + this.messageConverters = converters; + } + + /** + * Return the configured message converters. + */ + public List> getMessageConverters() { + return this.messageConverters; + } + + + @SuppressWarnings("unchecked") + protected Mono writeBody(ServerWebExchange exchange, Object body, + ResolvableType bodyType, MethodParameter bodyTypeParameter) { + + Publisher publisher = null; + ResolvableType elementType; + + if (Publisher.class.isAssignableFrom(bodyType.getRawClass())) { + publisher = (Publisher) body; + } + else { + TypeDescriptor descriptor = new TypeDescriptor(bodyTypeParameter); + if (getConversionService().canConvert(descriptor, MONO_TYPE)) { + publisher = (Publisher) getConversionService().convert(body, descriptor, MONO_TYPE); + } + else if (getConversionService().canConvert(descriptor, FLUX_TYPE)) { + publisher = (Publisher) getConversionService().convert(body, descriptor, FLUX_TYPE); + } + } + + if (publisher != null) { + elementType = bodyType.getGeneric(0); + } + else { + elementType = bodyType; + publisher = Mono.justOrEmpty(body); + } + + if (void.class == elementType.getRawClass() || Void.class == elementType.getRawClass()) { + return Mono.from((Publisher) publisher); + } + + List producibleTypes = getProducibleMediaTypes(elementType); + if (producibleTypes.isEmpty()) { + return Mono.error(new IllegalStateException( + "No converter for return value type: " + elementType)); + } + + MediaType bestMediaType = selectMediaType(exchange, producibleTypes); + + if (bestMediaType != null) { + for (HttpMessageConverter converter : getMessageConverters()) { + if (converter.canWrite(elementType, bestMediaType)) { + ServerHttpResponse response = exchange.getResponse(); + return converter.write((Publisher) publisher, elementType, bestMediaType, response); + } + } + } + + return Mono.error(new NotAcceptableStatusException(producibleTypes)); + } + + private List getProducibleMediaTypes(ResolvableType elementType) { + return getMessageConverters().stream() + .filter(converter -> converter.canWrite(elementType, null)) + .flatMap(converter -> converter.getWritableMediaTypes().stream()) + .collect(Collectors.toList()); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractNamedValueMethodArgumentResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractNamedValueMethodArgumentResolver.java new file mode 100644 index 0000000000..1db3ca782a --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractNamedValueMethodArgumentResolver.java @@ -0,0 +1,290 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import reactor.core.publisher.Mono; + +import org.springframework.beans.ConversionNotSupportedException; +import org.springframework.beans.SimpleTypeConverter; +import org.springframework.beans.TypeMismatchException; +import org.springframework.beans.factory.config.BeanExpressionContext; +import org.springframework.beans.factory.config.BeanExpressionResolver; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.core.MethodParameter; +import org.springframework.core.convert.ConversionService; +import org.springframework.ui.ModelMap; +import org.springframework.util.Assert; +import org.springframework.web.bind.annotation.ValueConstants; +import org.springframework.web.reactive.result.method.HandlerMethodArgumentResolver; +import org.springframework.web.server.ServerErrorException; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.ServerWebInputException; + +/** + * Abstract base class for resolving method arguments from a named value. + * Request parameters, request headers, and path variables are examples of named + * values. Each may have a name, a required flag, and a default value. + *

Subclasses define how to do the following: + *

    + *
  • Obtain named value information for a method parameter + *
  • Resolve names into argument values + *
  • Handle missing argument values when argument values are required + *
  • Optionally handle a resolved value + *
+ *

A default value string can contain ${...} placeholders and Spring Expression + * Language #{...} expressions. For this to work a + * {@link ConfigurableBeanFactory} must be supplied to the class constructor. + * + * @author Rossen Stoyanchev + */ +public abstract class AbstractNamedValueMethodArgumentResolver implements HandlerMethodArgumentResolver { + + private final ConfigurableBeanFactory configurableBeanFactory; + + private final BeanExpressionContext expressionContext; + + private final Map namedValueInfoCache = new ConcurrentHashMap<>(256); + + /** Instead of a WebDataBinder for now */ + private final SimpleTypeConverter typeConverter; + + + /** + * @param conversionService for type conversion (to be replaced with WebDataBinder) + * @param beanFactory a bean factory to use for resolving ${...} placeholder + * and #{...} SpEL expressions in default values, or {@code null} if default + * values are not expected to contain expressions + */ + public AbstractNamedValueMethodArgumentResolver(ConversionService conversionService, + ConfigurableBeanFactory beanFactory) { + + Assert.notNull(conversionService, "'conversionService' is required."); + this.typeConverter = new SimpleTypeConverter(); + this.typeConverter.setConversionService(conversionService); + this.configurableBeanFactory = beanFactory; + this.expressionContext = (beanFactory != null ? new BeanExpressionContext(beanFactory, null) : null); + } + + + @Override + public Mono resolveArgument(MethodParameter parameter, ModelMap model, ServerWebExchange exchange) { + NamedValueInfo namedValueInfo = getNamedValueInfo(parameter); + MethodParameter nestedParameter = parameter.nestedIfOptional(); + + Object resolvedName = resolveStringValue(namedValueInfo.name); + if (resolvedName == null) { + return Mono.error(new IllegalArgumentException( + "Specified name must not resolve to null: [" + namedValueInfo.name + "]")); + } + + return resolveName(resolvedName.toString(), nestedParameter, exchange) + .map(arg -> { + if ("".equals(arg) && namedValueInfo.defaultValue != null) { + arg = resolveStringValue(namedValueInfo.defaultValue); + } + arg = applyConversion(arg, parameter); + handleResolvedValue(arg, namedValueInfo.name, parameter, model, exchange); + return arg; + }) + .otherwiseIfEmpty(getDefaultValue(namedValueInfo, parameter, model, exchange)); + } + + /** + * Obtain the named value for the given method parameter. + */ + private NamedValueInfo getNamedValueInfo(MethodParameter parameter) { + NamedValueInfo namedValueInfo = this.namedValueInfoCache.get(parameter); + if (namedValueInfo == null) { + namedValueInfo = createNamedValueInfo(parameter); + namedValueInfo = updateNamedValueInfo(parameter, namedValueInfo); + this.namedValueInfoCache.put(parameter, namedValueInfo); + } + return namedValueInfo; + } + + /** + * Create the {@link NamedValueInfo} object for the given method parameter. + * Implementations typically retrieve the method annotation by means of + * {@link MethodParameter#getParameterAnnotation(Class)}. + * @param parameter the method parameter + * @return the named value information + */ + protected abstract NamedValueInfo createNamedValueInfo(MethodParameter parameter); + + /** + * Create a new NamedValueInfo based on the given NamedValueInfo with + * sanitized values. + */ + private NamedValueInfo updateNamedValueInfo(MethodParameter parameter, NamedValueInfo info) { + String name = info.name; + if (info.name.length() == 0) { + name = parameter.getParameterName(); + if (name == null) { + String type = parameter.getNestedParameterType().getName(); + throw new IllegalArgumentException("Name for argument type [" + type + "] not " + + "available, and parameter name information not found in class file either."); + } + } + String defaultValue = (ValueConstants.DEFAULT_NONE.equals(info.defaultValue) ? null : info.defaultValue); + return new NamedValueInfo(name, info.required, defaultValue); + } + + /** + * Resolve the given annotation-specified value, + * potentially containing placeholders and expressions. + */ + private Object resolveStringValue(String value) { + if (this.configurableBeanFactory == null) { + return value; + } + String placeholdersResolved = this.configurableBeanFactory.resolveEmbeddedValue(value); + BeanExpressionResolver exprResolver = this.configurableBeanFactory.getBeanExpressionResolver(); + if (exprResolver == null) { + return value; + } + return exprResolver.evaluate(placeholdersResolved, this.expressionContext); + } + + /** + * Resolve the given parameter type and value name into an argument value. + * @param name the name of the value being resolved + * @param parameter the method parameter to resolve to an argument value + * (pre-nested in case of a {@link java.util.Optional} declaration) + * @param exchange the current exchange + * @return the resolved argument (may be {@code null}) + */ + protected abstract Mono resolveName(String name, MethodParameter parameter, + ServerWebExchange exchange); + + private Object applyConversion(Object value, MethodParameter parameter) { + try { + value = this.typeConverter.convertIfNecessary(value, parameter.getParameterType(), parameter); + } + catch (ConversionNotSupportedException ex) { + throw new ServerErrorException("Conversion not supported.", parameter, ex); + } + catch (TypeMismatchException ex) { + throw new ServerWebInputException("Type mismatch.", parameter, ex); + } + return value; + } + + private Mono getDefaultValue(NamedValueInfo namedValueInfo, MethodParameter parameter, + ModelMap model, ServerWebExchange exchange) { + + Object value = null; + try { + if (namedValueInfo.defaultValue != null) { + value = resolveStringValue(namedValueInfo.defaultValue); + } + else if (namedValueInfo.required && !parameter.isOptional()) { + handleMissingValue(namedValueInfo.name, parameter, exchange); + } + value = handleNullValue(namedValueInfo.name, value, parameter.getNestedParameterType()); + value = applyConversion(value, parameter); + handleResolvedValue(value, namedValueInfo.name, parameter, model, exchange); + return Mono.justOrEmpty(value); + } + catch (Throwable ex) { + return Mono.error(ex); + } + } + + /** + * Invoked when a named value is required, but + * {@link #resolveName(String, MethodParameter, ServerWebExchange)} returned + * {@code null} and there is no default value. Subclasses typically throw an + * exception in this case. + * @param name the name for the value + * @param parameter the method parameter + * @param exchange the current exchange + */ + @SuppressWarnings("UnusedParameters") + protected void handleMissingValue(String name, MethodParameter parameter, ServerWebExchange exchange) { + handleMissingValue(name, parameter); + } + + /** + * Invoked when a named value is required, but + * {@link #resolveName(String, MethodParameter, ServerWebExchange)} returned + * {@code null} and there is no default value. Subclasses typically throw an + * exception in this case. + * @param name the name for the value + * @param parameter the method parameter + */ + protected void handleMissingValue(String name, MethodParameter parameter) { + String typeName = parameter.getNestedParameterType().getSimpleName(); + throw new ServerWebInputException("Missing argument '" + name + "' for method " + + "parameter of type " + typeName, parameter); + } + + /** + * A {@code null} results in a {@code false} value for {@code boolean}s or + * an exception for other primitives. + */ + private Object handleNullValue(String name, Object value, Class paramType) { + if (value == null) { + if (Boolean.TYPE.equals(paramType)) { + return Boolean.FALSE; + } + else if (paramType.isPrimitive()) { + throw new IllegalStateException("Optional " + paramType.getSimpleName() + + " parameter '" + name + "' is present but cannot be translated into a" + + " null value due to being declared as a primitive type. " + + "Consider declaring it as object wrapper for the corresponding primitive type."); + } + } + return value; + } + + /** + * Invoked after a value is resolved. + * @param arg the resolved argument value + * @param name the argument name + * @param parameter the argument parameter type + * @param model the model + * @param exchange the current exchange + */ + @SuppressWarnings("UnusedParameters") + protected void handleResolvedValue(Object arg, String name, MethodParameter parameter, + ModelMap model, ServerWebExchange exchange) { + } + + + /** + * Represents the information about a named value, including name, whether + * it's required and a default value. + */ + protected static class NamedValueInfo { + + private final String name; + + private final boolean required; + + private final String defaultValue; + + public NamedValueInfo(String name, boolean required, String defaultValue) { + this.name = name; + this.required = required; + this.defaultValue = defaultValue; + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/CookieValueMethodArgumentResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/CookieValueMethodArgumentResolver.java new file mode 100644 index 0000000000..48f239f9cc --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/CookieValueMethodArgumentResolver.java @@ -0,0 +1,93 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import reactor.core.publisher.Mono; + +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.core.MethodParameter; +import org.springframework.core.convert.ConversionService; +import org.springframework.http.HttpCookie; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.ServerWebInputException; + +/** + * Resolve method arguments annotated with {@code @CookieValue}. + * + *

An {@code @CookieValue} is a named value that is resolved from a cookie. + * It has a required flag and a default value to fall back on when the cookie + * does not exist. + * + * @author Rossen Stoyanchev + */ +public class CookieValueMethodArgumentResolver extends AbstractNamedValueMethodArgumentResolver { + + + /** + * @param beanFactory a bean factory to use for resolving ${...} + * placeholder and #{...} SpEL expressions in default values; + * or {@code null} if default values are not expected to contain expressions + */ + public CookieValueMethodArgumentResolver(ConversionService conversionService, + ConfigurableBeanFactory beanFactory) { + + super(conversionService, beanFactory); + } + + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(CookieValue.class); + } + + @Override + protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) { + CookieValue annotation = parameter.getParameterAnnotation(CookieValue.class); + return new CookieValueNamedValueInfo(annotation); + } + + @Override + protected Mono resolveName(String name, MethodParameter parameter, ServerWebExchange exchange) { + HttpCookie cookie = exchange.getRequest().getCookies().getFirst(name); + if (HttpCookie.class.isAssignableFrom(parameter.getNestedParameterType())) { + return Mono.justOrEmpty(cookie); + } + else if (cookie != null) { + return Mono.justOrEmpty(cookie.getValue()); + } + else { + return Mono.empty(); + } + } + + @Override + protected void handleMissingValue(String name, MethodParameter parameter) { + String type = parameter.getNestedParameterType().getSimpleName(); + String reason = "Missing cookie '" + name + "' for method parameter of type " + type; + throw new ServerWebInputException(reason, parameter); + } + + + private static class CookieValueNamedValueInfo extends NamedValueInfo { + + private CookieValueNamedValueInfo(CookieValue annotation) { + super(annotation.name(), annotation.required(), annotation.defaultValue()); + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/ExpressionValueMethodArgumentResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/ExpressionValueMethodArgumentResolver.java new file mode 100644 index 0000000000..fb6f22d659 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/ExpressionValueMethodArgumentResolver.java @@ -0,0 +1,81 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import reactor.core.publisher.Mono; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.core.MethodParameter; +import org.springframework.core.convert.ConversionService; +import org.springframework.web.server.ServerWebExchange; + +/** + * Resolves method arguments annotated with {@code @Value}. + * + *

An {@code @Value} does not have a name but gets resolved from the default + * value string, which may contain ${...} placeholder or Spring Expression + * Language #{...} expressions. + * + * @author Rossen Stoyanchev + */ +public class ExpressionValueMethodArgumentResolver extends AbstractNamedValueMethodArgumentResolver { + + + /** + * @param beanFactory a bean factory to use for resolving ${...} + * placeholder and #{...} SpEL expressions in default values; + * or {@code null} if default values are not expected to contain expressions + */ + public ExpressionValueMethodArgumentResolver(ConversionService conversionService, + ConfigurableBeanFactory beanFactory) { + + super(conversionService, beanFactory); + } + + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(Value.class); + } + + @Override + protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) { + Value annotation = parameter.getParameterAnnotation(Value.class); + return new ExpressionValueNamedValueInfo(annotation); + } + + @Override + protected Mono resolveName(String name, MethodParameter parameter, ServerWebExchange exchange) { + // No name to resolve + return Mono.empty(); + } + + @Override + protected void handleMissingValue(String name, MethodParameter parameter) { + throw new UnsupportedOperationException("@Value is never required: " + parameter.getMethod()); + } + + + private static class ExpressionValueNamedValueInfo extends NamedValueInfo { + + private ExpressionValueNamedValueInfo(Value annotation) { + super("@Value", false, annotation.value()); + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/HttpEntityArgumentResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/HttpEntityArgumentResolver.java new file mode 100644 index 0000000000..0cd9bcf6b2 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/HttpEntityArgumentResolver.java @@ -0,0 +1,112 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import java.util.List; + +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.core.ResolvableType; +import org.springframework.core.convert.ConversionService; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.RequestEntity; +import org.springframework.http.converter.reactive.HttpMessageConverter; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.ui.ModelMap; +import org.springframework.validation.Validator; +import org.springframework.web.reactive.result.method.HandlerMethodArgumentResolver; +import org.springframework.web.server.ServerWebExchange; + +/** + * Resolves method arguments of type {@link HttpEntity} or {@link RequestEntity} + * by reading the body of the request through a compatible + * {@code HttpMessageConverter}. + * + * @author Rossen Stoyanchev + */ +public class HttpEntityArgumentResolver extends AbstractMessageConverterArgumentResolver + implements HandlerMethodArgumentResolver { + + + /** + * Constructor with message converters and a ConversionService. + * @param converters converters for reading the request body with + * @param service for converting to other reactive types from Flux and Mono + */ + public HttpEntityArgumentResolver(List> converters, + ConversionService service) { + + this(converters, service, null); + } + + /** + * Constructor with message converters and a ConversionService. + * @param converters converters for reading the request body with + * @param service for converting to other reactive types from Flux and Mono + * @param validator validator to validate decoded objects with + */ + public HttpEntityArgumentResolver(List> converters, + ConversionService service, Validator validator) { + + super(converters, service, validator); + } + + + @Override + public boolean supportsParameter(MethodParameter parameter) { + Class clazz = parameter.getParameterType(); + return (HttpEntity.class.equals(clazz) || RequestEntity.class.equals(clazz)); + } + + @Override + public Mono resolveArgument(MethodParameter param, ModelMap model, ServerWebExchange exchange) { + + ResolvableType entityType; + MethodParameter bodyParameter; + + if (getConversionService().canConvert(Mono.class, param.getParameterType())) { + entityType = ResolvableType.forMethodParameter(param).getGeneric(0); + bodyParameter = new MethodParameter(param); + bodyParameter.increaseNestingLevel(); + bodyParameter.increaseNestingLevel(); + } + else { + entityType = ResolvableType.forMethodParameter(param); + bodyParameter = new MethodParameter(param); + bodyParameter.increaseNestingLevel(); + } + + return readBody(bodyParameter, false, exchange) + .map(body -> createHttpEntity(body, entityType, exchange)) + .defaultIfEmpty(createHttpEntity(null, entityType, exchange)); + } + + private Object createHttpEntity(Object body, ResolvableType entityType, + ServerWebExchange exchange) { + + ServerHttpRequest request = exchange.getRequest(); + HttpHeaders headers = request.getHeaders(); + if (RequestEntity.class == entityType.getRawClass()) { + return new RequestEntity<>(body, headers, request.getMethod(), request.getURI()); + } + else { + return new HttpEntity<>(body, headers); + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/ModelArgumentResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/ModelArgumentResolver.java new file mode 100644 index 0000000000..dbcd43b3d6 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/ModelArgumentResolver.java @@ -0,0 +1,44 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.ui.Model; +import org.springframework.ui.ModelMap; +import org.springframework.web.reactive.result.method.HandlerMethodArgumentResolver; +import org.springframework.web.server.ServerWebExchange; + +/** + * Resolver for the {@link Model} controller method argument. + * + * @author Rossen Stoyanchev + */ +public class ModelArgumentResolver implements HandlerMethodArgumentResolver { + + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return Model.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Mono resolveArgument(MethodParameter parameter, ModelMap model, ServerWebExchange exchange) { + return Mono.just(model); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/PathVariableMapMethodArgumentResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/PathVariableMapMethodArgumentResolver.java new file mode 100644 index 0000000000..9488bfa69c --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/PathVariableMapMethodArgumentResolver.java @@ -0,0 +1,64 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import java.util.Collections; +import java.util.Map; +import java.util.Optional; + +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.ui.ModelMap; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.reactive.HandlerMapping; +import org.springframework.web.reactive.result.method.HandlerMethodArgumentResolver; +import org.springframework.web.server.ServerWebExchange; + +/** + * Resolver for {@link Map} method arguments also annotated with + * {@link PathVariable @PathVariable} where the annotation does not specify a + * path variable name. The resulting {@link Map} argument is a coyp of all URI + * template name-value pairs. + * + * @author Rossen Stoyanchev + * @see PathVariableMethodArgumentResolver + */ +public class PathVariableMapMethodArgumentResolver implements HandlerMethodArgumentResolver { + + + @Override + public boolean supportsParameter(MethodParameter parameter) { + PathVariable ann = parameter.getParameterAnnotation(PathVariable.class); + return (ann != null && (Map.class.isAssignableFrom(parameter.getParameterType())) + && !StringUtils.hasText(ann.value())); + } + + /** + * Return a Map with all URI template variables or an empty map. + */ + @Override + public Mono resolveArgument(MethodParameter parameter, ModelMap model, + ServerWebExchange exchange) { + + String name = HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE; + Optional value = exchange.getAttribute(name); + return (value.isPresent() ? Mono.just(value.get()) : Mono.just(Collections.emptyMap())); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/PathVariableMethodArgumentResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/PathVariableMethodArgumentResolver.java new file mode 100644 index 0000000000..fd59d53e7c --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/PathVariableMethodArgumentResolver.java @@ -0,0 +1,114 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import java.util.Map; +import java.util.Optional; + +import reactor.core.publisher.Mono; + +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.core.MethodParameter; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.converter.Converter; +import org.springframework.ui.ModelMap; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.ValueConstants; +import org.springframework.web.reactive.HandlerMapping; +import org.springframework.web.server.ServerErrorException; +import org.springframework.web.server.ServerWebExchange; + +/** + * Resolves method arguments annotated with @{@link PathVariable}. + * + *

An @{@link PathVariable} is a named value that gets resolved from a URI + * template variable. It is always required and does not have a default value + * to fall back on. See the base class + * {@link org.springframework.web.method.annotation.AbstractNamedValueMethodArgumentResolver} + * for more information on how named values are processed. + * + *

If the method parameter type is {@link Map}, the name specified in the + * annotation is used to resolve the URI variable String value. The value is + * then converted to a {@link Map} via type conversion, assuming a suitable + * {@link Converter}. + * + * @author Rossen Stoyanchev + * @see PathVariableMapMethodArgumentResolver + */ +public class PathVariableMethodArgumentResolver extends AbstractNamedValueMethodArgumentResolver { + + + public PathVariableMethodArgumentResolver(ConversionService conversionService, + ConfigurableBeanFactory beanFactory) { + + super(conversionService, beanFactory); + } + + + @Override + public boolean supportsParameter(MethodParameter parameter) { + if (!parameter.hasParameterAnnotation(PathVariable.class)) { + return false; + } + if (Map.class.isAssignableFrom(parameter.nestedIfOptional().getNestedParameterType())) { + String paramName = parameter.getParameterAnnotation(PathVariable.class).value(); + return StringUtils.hasText(paramName); + } + return true; + } + + @Override + protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) { + PathVariable annotation = parameter.getParameterAnnotation(PathVariable.class); + return new PathVariableNamedValueInfo(annotation); + } + + @Override + @SuppressWarnings("unchecked") + protected Mono resolveName(String name, MethodParameter parameter, ServerWebExchange exchange) { + String attributeName = HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE; + Optional optional = exchange.getAttribute(attributeName); + Object value = null; + if (optional.isPresent()) { + value = ((Map) optional.get()).get(name); + } + return Mono.justOrEmpty(value); + } + + @Override + protected void handleMissingValue(String name, MethodParameter parameter) { + throw new ServerErrorException(name, parameter); + } + + @Override + @SuppressWarnings("unchecked") + protected void handleResolvedValue(Object arg, String name, MethodParameter parameter, + ModelMap model, ServerWebExchange exchange) { + + // TODO: View.PATH_VARIABLES ? + } + + + private static class PathVariableNamedValueInfo extends NamedValueInfo { + + public PathVariableNamedValueInfo(PathVariable annotation) { + super(annotation.value(), true, ValueConstants.DEFAULT_NONE); + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestAttributeMethodArgumentResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestAttributeMethodArgumentResolver.java new file mode 100644 index 0000000000..f14e9fdd35 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestAttributeMethodArgumentResolver.java @@ -0,0 +1,68 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import reactor.core.publisher.Mono; + +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.core.MethodParameter; +import org.springframework.core.convert.ConversionService; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.ValueConstants; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.ServerWebInputException; + +/** + * Resolves method arguments annotated with an @{@link RequestAttribute}. + * + * @author Rossen Stoyanchev + * @see SessionAttributeMethodArgumentResolver + */ +public class RequestAttributeMethodArgumentResolver extends AbstractNamedValueMethodArgumentResolver { + + + public RequestAttributeMethodArgumentResolver(ConversionService conversionService, + ConfigurableBeanFactory beanFactory) { + + super(conversionService, beanFactory); + } + + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(RequestAttribute.class); + } + + + @Override + protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) { + RequestAttribute annot = parameter.getParameterAnnotation(RequestAttribute.class); + return new NamedValueInfo(annot.name(), annot.required(), ValueConstants.DEFAULT_NONE); + } + + @Override + protected Mono resolveName(String name, MethodParameter parameter, ServerWebExchange exchange){ + return Mono.justOrEmpty(exchange.getAttribute(name)); + } + + @Override + protected void handleMissingValue(String name, MethodParameter parameter) { + String type = parameter.getNestedParameterType().getSimpleName(); + String reason = "Missing request attribute '" + name + "' of type " + type; + throw new ServerWebInputException(reason, parameter); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestBodyArgumentResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestBodyArgumentResolver.java new file mode 100644 index 0000000000..fc12bbeb26 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestBodyArgumentResolver.java @@ -0,0 +1,85 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import java.util.List; + +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.core.convert.ConversionService; +import org.springframework.http.converter.reactive.HttpMessageConverter; +import org.springframework.ui.ModelMap; +import org.springframework.validation.Validator; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.reactive.result.method.HandlerMethodArgumentResolver; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.ServerWebInputException; + +/** + * Resolves method arguments annotated with {@code @RequestBody} by reading the + * body of the request through a compatible {@code HttpMessageConverter}. + * + *

An {@code @RequestBody} method argument is also validated if it is + * annotated with {@code @javax.validation.Valid} or + * {@link org.springframework.validation.annotation.Validated}. Validation + * failure results in an {@link ServerWebInputException}. + * + * @author Sebastien Deleuze + * @author Stephane Maldini + * @author Rossen Stoyanchev + */ +public class RequestBodyArgumentResolver extends AbstractMessageConverterArgumentResolver + implements HandlerMethodArgumentResolver { + + + /** + * Constructor with message converters and a ConversionService. + * @param converters converters for reading the request body with + * @param service for converting to other reactive types from Flux and Mono + */ + public RequestBodyArgumentResolver(List> converters, + ConversionService service) { + + this(converters, service, null); + } + + /** + * Constructor with message converters and a ConversionService. + * @param converters converters for reading the request body with + * @param service for converting to other reactive types from Flux and Mono + * @param validator validator to validate decoded objects with + */ + public RequestBodyArgumentResolver(List> converters, + ConversionService service, Validator validator) { + + super(converters, service, validator); + } + + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(RequestBody.class); + } + + @Override + public Mono resolveArgument(MethodParameter param, ModelMap model, ServerWebExchange exchange) { + boolean isRequired = param.getParameterAnnotation(RequestBody.class).required(); + return readBody(param, isRequired, exchange); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestHeaderMapMethodArgumentResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestHeaderMapMethodArgumentResolver.java new file mode 100644 index 0000000000..4f3820e9c2 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestHeaderMapMethodArgumentResolver.java @@ -0,0 +1,62 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import java.util.Map; + +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpHeaders; +import org.springframework.ui.ModelMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.reactive.result.method.HandlerMethodArgumentResolver; +import org.springframework.web.server.ServerWebExchange; + +/** + * Resolves {@link Map} method arguments annotated with {@code @RequestHeader}. + * For individual header values annotated with {@code @RequestHeader} see + * {@link RequestHeaderMethodArgumentResolver} instead. + * + *

The created {@link Map} contains all request header name/value pairs. + * The method parameter type may be a {@link MultiValueMap} to receive all + * values for a header, not only the first one. + * + * @author Rossen Stoyanchev + * @see RequestHeaderMethodArgumentResolver + */ +public class RequestHeaderMapMethodArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return (parameter.hasParameterAnnotation(RequestHeader.class) && + Map.class.isAssignableFrom(parameter.getParameterType())); + } + + @Override + public Mono resolveArgument(MethodParameter parameter, ModelMap model, ServerWebExchange exchange) { + HttpHeaders headers = exchange.getRequest().getHeaders(); + if (MultiValueMap.class.isAssignableFrom(parameter.getParameterType())) { + return Mono.just(headers); + } + else { + return Mono.just(headers.toSingleValueMap()); + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestHeaderMethodArgumentResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestHeaderMethodArgumentResolver.java new file mode 100644 index 0000000000..4f3cd089c5 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestHeaderMethodArgumentResolver.java @@ -0,0 +1,97 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import java.util.List; +import java.util.Map; + +import reactor.core.publisher.Mono; + +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.core.MethodParameter; +import org.springframework.core.convert.ConversionService; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.ServerWebInputException; + +/** + * Resolves method arguments annotated with {@code @RequestHeader} except for + * {@link Map} arguments. See {@link RequestHeaderMapMethodArgumentResolver} for + * details on {@link Map} arguments annotated with {@code @RequestHeader}. + * + *

An {@code @RequestHeader} is a named value resolved from a request header. + * It has a required flag and a default value to fall back on when the request + * header does not exist. + * + *

A {@link ConversionService} is invoked to apply type conversion to resolved + * request header values that don't yet match the method parameter type. + * + * @author Rossen Stoyanchev + * @see RequestHeaderMapMethodArgumentResolver + */ +public class RequestHeaderMethodArgumentResolver extends AbstractNamedValueMethodArgumentResolver { + + /** + * @param beanFactory a bean factory to use for resolving ${...} + * placeholder and #{...} SpEL expressions in default values; + * or {@code null} if default values are not expected to have expressions + */ + public RequestHeaderMethodArgumentResolver(ConversionService conversionService, + ConfigurableBeanFactory beanFactory) { + + super(conversionService, beanFactory); + } + + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return (parameter.hasParameterAnnotation(RequestHeader.class) && + !Map.class.isAssignableFrom(parameter.nestedIfOptional().getNestedParameterType())); + } + + @Override + protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) { + RequestHeader annotation = parameter.getParameterAnnotation(RequestHeader.class); + return new RequestHeaderNamedValueInfo(annotation); + } + + @Override + protected Mono resolveName(String name, MethodParameter parameter, ServerWebExchange exchange) { + List headerValues = exchange.getRequest().getHeaders().get(name); + Object result = null; + if (headerValues != null) { + result = (headerValues.size() == 1 ? headerValues.get(0) : headerValues); + } + return Mono.justOrEmpty(result); + } + + @Override + protected void handleMissingValue(String name, MethodParameter parameter) { + String type = parameter.getNestedParameterType().getSimpleName(); + throw new ServerWebInputException("Missing request header '" + name + + "' for method parameter of type " + type); + } + + + private static class RequestHeaderNamedValueInfo extends NamedValueInfo { + + private RequestHeaderNamedValueInfo(RequestHeader annotation) { + super(annotation.name(), annotation.required(), annotation.defaultValue()); + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerAdapter.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerAdapter.java new file mode 100644 index 0000000000..7da5694419 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerAdapter.java @@ -0,0 +1,270 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import reactor.core.publisher.Mono; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.core.codec.ByteBufferDecoder; +import org.springframework.core.codec.StringDecoder; +import org.springframework.core.convert.ConversionService; +import org.springframework.format.support.DefaultFormattingConversionService; +import org.springframework.http.converter.reactive.CodecHttpMessageConverter; +import org.springframework.http.converter.reactive.HttpMessageConverter; +import org.springframework.ui.ExtendedModelMap; +import org.springframework.ui.ModelMap; +import org.springframework.validation.Validator; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.method.annotation.ExceptionHandlerMethodResolver; +import org.springframework.web.reactive.HandlerAdapter; +import org.springframework.web.reactive.HandlerResult; +import org.springframework.web.reactive.result.method.HandlerMethodArgumentResolver; +import org.springframework.web.reactive.result.method.InvocableHandlerMethod; +import org.springframework.web.server.ServerWebExchange; + + +/** + * Supports the invocation of {@code @RequestMapping} methods. + * + * @author Rossen Stoyanchev + */ +public class RequestMappingHandlerAdapter implements HandlerAdapter, BeanFactoryAware, InitializingBean { + + private static Log logger = LogFactory.getLog(RequestMappingHandlerAdapter.class); + + + private List customArgumentResolvers; + + private List argumentResolvers; + + private final List> messageConverters = new ArrayList<>(10); + + private ConversionService conversionService = new DefaultFormattingConversionService(); + + private Validator validator; + + private ConfigurableBeanFactory beanFactory; + + private final Map, ExceptionHandlerMethodResolver> exceptionHandlerCache = new ConcurrentHashMap<>(64); + + + + public RequestMappingHandlerAdapter() { + this.messageConverters.add(new CodecHttpMessageConverter<>(new ByteBufferDecoder())); + this.messageConverters.add(new CodecHttpMessageConverter<>(new StringDecoder())); + } + + + /** + * Provide custom argument resolvers without overriding the built-in ones. + */ + public void setCustomArgumentResolvers(List argumentResolvers) { + this.customArgumentResolvers = argumentResolvers; + } + + /** + * Return the custom argument resolvers. + */ + public List getCustomArgumentResolvers() { + return this.customArgumentResolvers; + } + + /** + * Configure the complete list of supported argument types thus overriding + * the resolvers that would otherwise be configured by default. + */ + public void setArgumentResolvers(List resolvers) { + this.argumentResolvers = new ArrayList<>(resolvers); + } + + /** + * Return the configured argument resolvers. + */ + public List getArgumentResolvers() { + return this.argumentResolvers; + } + + /** + * Configure message converters to read the request body with. + */ + public void setMessageConverters(List> messageConverters) { + this.messageConverters.clear(); + this.messageConverters.addAll(messageConverters); + } + + /** + * Return the configured message converters. + */ + public List> getMessageConverters() { + return this.messageConverters; + } + + /** + * Configure a ConversionService for type conversion of controller method + * arguments as well as for converting from different async types to + * {@code Flux} and {@code Mono}. + * + * TODO: this may be replaced by DataBinder + */ + public void setConversionService(ConversionService conversionService) { + this.conversionService = conversionService; + } + + /** + * Return the configured ConversionService. + */ + public ConversionService getConversionService() { + return this.conversionService; + } + + /** + * Configure a Validator for validation of controller method arguments such + * as {@code @RequestBody}. + * + * TODO: this may be replaced by DataBinder + */ + public void setValidator(Validator validator) { + this.validator = validator; + } + + /** + * Return the configured Validator. + */ + public Validator getValidator() { + return this.validator; + } + + /** + * A {@link ConfigurableBeanFactory} is expected for resolving expressions + * in method argument default values. + */ + @Override + public void setBeanFactory(BeanFactory beanFactory) throws BeansException { + if (beanFactory instanceof ConfigurableBeanFactory) { + this.beanFactory = (ConfigurableBeanFactory) beanFactory; + } + } + + public ConfigurableBeanFactory getBeanFactory() { + return this.beanFactory; + } + + + @Override + public void afterPropertiesSet() throws Exception { + if (this.argumentResolvers == null) { + this.argumentResolvers = initArgumentResolvers(); + } + } + + protected List initArgumentResolvers() { + List resolvers = new ArrayList<>(); + + // Annotation-based argument resolution + ConversionService cs = getConversionService(); + resolvers.add(new RequestParamMethodArgumentResolver(cs, getBeanFactory(), false)); + resolvers.add(new RequestParamMapMethodArgumentResolver()); + resolvers.add(new PathVariableMethodArgumentResolver(cs, getBeanFactory())); + resolvers.add(new PathVariableMapMethodArgumentResolver()); + resolvers.add(new RequestBodyArgumentResolver(getMessageConverters(), cs, getValidator())); + resolvers.add(new RequestHeaderMethodArgumentResolver(cs, getBeanFactory())); + resolvers.add(new RequestHeaderMapMethodArgumentResolver()); + resolvers.add(new CookieValueMethodArgumentResolver(cs, getBeanFactory())); + resolvers.add(new ExpressionValueMethodArgumentResolver(cs, getBeanFactory())); + resolvers.add(new SessionAttributeMethodArgumentResolver(cs, getBeanFactory())); + resolvers.add(new RequestAttributeMethodArgumentResolver(cs , getBeanFactory())); + + // Type-based argument resolution + resolvers.add(new ModelArgumentResolver()); + + // Custom resolvers + if (getCustomArgumentResolvers() != null) { + resolvers.addAll(getCustomArgumentResolvers()); + } + + // Catch-all + resolvers.add(new RequestParamMethodArgumentResolver(cs, getBeanFactory(), true)); + return resolvers; + } + + @Override + public boolean supports(Object handler) { + return HandlerMethod.class.equals(handler.getClass()); + } + + @Override + public Mono handle(ServerWebExchange exchange, Object handler) { + HandlerMethod handlerMethod = (HandlerMethod) handler; + InvocableHandlerMethod invocable = new InvocableHandlerMethod(handlerMethod); + invocable.setHandlerMethodArgumentResolvers(getArgumentResolvers()); + ModelMap model = new ExtendedModelMap(); + return invocable.invokeForRequest(exchange, model) + .map(result -> result.setExceptionHandler(ex -> handleException(ex, handlerMethod, exchange))) + .otherwise(ex -> handleException(ex, handlerMethod, exchange)); + } + + private Mono handleException(Throwable ex, HandlerMethod handlerMethod, + ServerWebExchange exchange) { + + if (ex instanceof Exception) { + InvocableHandlerMethod invocable = findExceptionHandler(handlerMethod, (Exception) ex); + if (invocable != null) { + try { + if (logger.isDebugEnabled()) { + logger.debug("Invoking @ExceptionHandler method: " + invocable); + } + invocable.setHandlerMethodArgumentResolvers(getArgumentResolvers()); + ExtendedModelMap errorModel = new ExtendedModelMap(); + return invocable.invokeForRequest(exchange, errorModel, ex); + } + catch (Exception invocationEx) { + if (logger.isErrorEnabled()) { + logger.error("Failed to invoke @ExceptionHandler method: " + invocable, invocationEx); + } + } + } + } + return Mono.error(ex); + } + + protected InvocableHandlerMethod findExceptionHandler(HandlerMethod handlerMethod, Exception exception) { + if (handlerMethod == null) { + return null; + } + Class handlerType = handlerMethod.getBeanType(); + ExceptionHandlerMethodResolver resolver = this.exceptionHandlerCache.get(handlerType); + if (resolver == null) { + resolver = new ExceptionHandlerMethodResolver(handlerType); + this.exceptionHandlerCache.put(handlerType, resolver); + } + Method method = resolver.resolveMethod(exception); + return (method != null ? new InvocableHandlerMethod(handlerMethod.getBean(), method) : null); + } + +} \ No newline at end of file diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMapping.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMapping.java new file mode 100644 index 0000000000..879f34cdc4 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMapping.java @@ -0,0 +1,275 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Method; +import java.util.Set; + +import org.springframework.context.EmbeddedValueResolverAware; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.stereotype.Controller; +import org.springframework.util.Assert; +import org.springframework.util.StringValueResolver; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.reactive.accept.RequestedContentTypeResolverBuilder; +import org.springframework.web.reactive.accept.RequestedContentTypeResolver; +import org.springframework.web.reactive.result.condition.RequestCondition; +import org.springframework.web.reactive.result.method.RequestMappingInfo; +import org.springframework.web.reactive.result.method.RequestMappingInfoHandlerMapping; + +/** + * An extension of {@link RequestMappingInfoHandlerMapping} that creates + * {@link RequestMappingInfo} instances from class-level and method-level + * {@link RequestMapping @RequestMapping} annotations. + * + * @author Rossen Stoyanchev + */ +public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMapping + implements EmbeddedValueResolverAware { + + private boolean useSuffixPatternMatch = true; + + private boolean useRegisteredSuffixPatternMatch = true; + + private boolean useTrailingSlashMatch = true; + + private RequestedContentTypeResolver contentTypeResolver = new RequestedContentTypeResolverBuilder().build(); + + private StringValueResolver embeddedValueResolver; + + private RequestMappingInfo.BuilderConfiguration config = new RequestMappingInfo.BuilderConfiguration(); + + + /** + * Whether to use suffix pattern matching. If enabled a method mapped to + * "/path" also matches to "/path.*". + *

The default value is {@code true}. + *

Note: when using suffix pattern matching it's usually + * preferable to be explicit about what is and isn't an extension so rather + * than setting this property consider using + * {@link #setUseRegisteredSuffixPatternMatch} instead. + */ + public void setUseSuffixPatternMatch(boolean useSuffixPatternMatch) { + this.useSuffixPatternMatch = useSuffixPatternMatch; + } + + /** + * Whether suffix pattern matching should work only against path extensions + * explicitly registered with the configured {@link RequestedContentTypeResolver}. This + * is generally recommended to reduce ambiguity and to avoid issues such as + * when a "." appears in the path for other reasons. + *

By default this is set to "true". + */ + public void setUseRegisteredSuffixPatternMatch(boolean useRegisteredSuffixPatternMatch) { + this.useRegisteredSuffixPatternMatch = useRegisteredSuffixPatternMatch; + this.useSuffixPatternMatch = (useRegisteredSuffixPatternMatch || this.useSuffixPatternMatch); + } + + /** + * Whether to match to URLs irrespective of the presence of a trailing slash. + * If enabled a method mapped to "/users" also matches to "/users/". + *

The default value is {@code true}. + */ + public void setUseTrailingSlashMatch(boolean useTrailingSlashMatch) { + this.useTrailingSlashMatch = useTrailingSlashMatch; + } + + /** + * Set the {@link RequestedContentTypeResolver} to use to determine requested media types. + * If not set, the default constructor is used. + */ + public void setContentTypeResolver(RequestedContentTypeResolver contentTypeResolver) { + Assert.notNull(contentTypeResolver, "'contentTypeResolver' must not be null"); + this.contentTypeResolver = contentTypeResolver; + } + + @Override + public void setEmbeddedValueResolver(StringValueResolver resolver) { + this.embeddedValueResolver = resolver; + } + + @Override + public void afterPropertiesSet() { + this.config = new RequestMappingInfo.BuilderConfiguration(); + this.config.setPathHelper(getPathHelper()); + this.config.setPathMatcher(getPathMatcher()); + this.config.setSuffixPatternMatch(this.useSuffixPatternMatch); + this.config.setTrailingSlashMatch(this.useTrailingSlashMatch); + this.config.setRegisteredSuffixPatternMatch(this.useRegisteredSuffixPatternMatch); + this.config.setContentTypeResolver(getContentTypeResolver()); + + super.afterPropertiesSet(); + } + + + /** + * Whether to use suffix pattern matching. + */ + public boolean useSuffixPatternMatch() { + return this.useSuffixPatternMatch; + } + + /** + * Whether to use registered suffixes for pattern matching. + */ + public boolean useRegisteredSuffixPatternMatch() { + return this.useRegisteredSuffixPatternMatch; + } + + /** + * Whether to match to URLs irrespective of the presence of a trailing slash. + */ + public boolean useTrailingSlashMatch() { + return this.useTrailingSlashMatch; + } + + /** + * Return the configured {@link RequestedContentTypeResolver}. + */ + public RequestedContentTypeResolver getContentTypeResolver() { + return this.contentTypeResolver; + } + + /** + * Return the file extensions to use for suffix pattern matching. + */ + public Set getFileExtensions() { + return this.config.getFileExtensions(); + } + + + /** + * {@inheritDoc} + * Expects a handler to have a type-level @{@link Controller} annotation. + */ + @Override + protected boolean isHandler(Class beanType) { + return (AnnotatedElementUtils.hasAnnotation(beanType, Controller.class) || + AnnotatedElementUtils.hasAnnotation(beanType, RequestMapping.class)); + } + + /** + * Uses method and type-level @{@link RequestMapping} annotations to create + * the RequestMappingInfo. + * @return the created RequestMappingInfo, or {@code null} if the method + * does not have a {@code @RequestMapping} annotation. + * @see #getCustomMethodCondition(Method) + * @see #getCustomTypeCondition(Class) + */ + @Override + protected RequestMappingInfo getMappingForMethod(Method method, Class handlerType) { + RequestMappingInfo info = createRequestMappingInfo(method); + if (info != null) { + RequestMappingInfo typeInfo = createRequestMappingInfo(handlerType); + if (typeInfo != null) { + info = typeInfo.combine(info); + } + } + return info; + } + + /** + * Delegates to {@link #createRequestMappingInfo(RequestMapping, RequestCondition)}, + * supplying the appropriate custom {@link RequestCondition} depending on whether + * the supplied {@code annotatedElement} is a class or method. + * @see #getCustomTypeCondition(Class) + * @see #getCustomMethodCondition(Method) + */ + private RequestMappingInfo createRequestMappingInfo(AnnotatedElement element) { + RequestMapping requestMapping = AnnotatedElementUtils.findMergedAnnotation(element, RequestMapping.class); + RequestCondition condition = (element instanceof Class ? + getCustomTypeCondition((Class) element) : getCustomMethodCondition((Method) element)); + return (requestMapping != null ? createRequestMappingInfo(requestMapping, condition) : null); + } + + /** + * Provide a custom type-level request condition. + * The custom {@link RequestCondition} can be of any type so long as the + * same condition type is returned from all calls to this method in order + * to ensure custom request conditions can be combined and compared. + *

Consider extending + * {@link org.springframework.web.reactive.result.condition.AbstractRequestCondition + * AbstractRequestCondition} for custom condition types and using + * {@link org.springframework.web.reactive.result.condition.CompositeRequestCondition + * CompositeRequestCondition} to provide multiple custom conditions. + * @param handlerType the handler type for which to create the condition + * @return the condition, or {@code null} + */ + @SuppressWarnings("UnusedParameters") + protected RequestCondition getCustomTypeCondition(Class handlerType) { + return null; + } + + /** + * Provide a custom method-level request condition. + * The custom {@link RequestCondition} can be of any type so long as the + * same condition type is returned from all calls to this method in order + * to ensure custom request conditions can be combined and compared. + *

Consider extending + * {@link org.springframework.web.reactive.result.condition.AbstractRequestCondition + * AbstractRequestCondition} for custom condition types and using + * {@link org.springframework.web.reactive.result.condition.CompositeRequestCondition + * CompositeRequestCondition} to provide multiple custom conditions. + * @param method the handler method for which to create the condition + * @return the condition, or {@code null} + */ + @SuppressWarnings("UnusedParameters") + protected RequestCondition getCustomMethodCondition(Method method) { + return null; + } + + /** + * Create a {@link RequestMappingInfo} from the supplied + * {@link RequestMapping @RequestMapping} annotation, which is either + * a directly declared annotation, a meta-annotation, or the synthesized + * result of merging annotation attributes within an annotation hierarchy. + */ + protected RequestMappingInfo createRequestMappingInfo( + RequestMapping requestMapping, RequestCondition customCondition) { + + return RequestMappingInfo + .paths(resolveEmbeddedValuesInPatterns(requestMapping.path())) + .methods(requestMapping.method()) + .params(requestMapping.params()) + .headers(requestMapping.headers()) + .consumes(requestMapping.consumes()) + .produces(requestMapping.produces()) + .mappingName(requestMapping.name()) + .customCondition(customCondition) + .options(this.config) + .build(); + } + + /** + * Resolve placeholder values in the given array of patterns. + * @return a new array with updated patterns + */ + protected String[] resolveEmbeddedValuesInPatterns(String[] patterns) { + if (this.embeddedValueResolver == null) { + return patterns; + } + else { + String[] resolvedPatterns = new String[patterns.length]; + for (int i = 0; i < patterns.length; i++) { + resolvedPatterns[i] = this.embeddedValueResolver.resolveStringValue(patterns[i]); + } + return resolvedPatterns; + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestParamMapMethodArgumentResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestParamMapMethodArgumentResolver.java new file mode 100644 index 0000000000..50df6d3c4a --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestParamMapMethodArgumentResolver.java @@ -0,0 +1,69 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import java.util.Map; + +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.ui.ModelMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.reactive.result.method.HandlerMethodArgumentResolver; +import org.springframework.web.server.ServerWebExchange; + +/** + * Resolver for {@link Map} method arguments annotated with + * {@link RequestParam @RequestParam} where the annotation does not specify a + * request parameter name. See {@link RequestParamMethodArgumentResolver} for + * resolving {@link Map} method arguments with a request parameter name. + * + *

The created {@link Map} contains all request parameter name-value pairs. + * If the method parameter type is {@link MultiValueMap} instead, the created + * map contains all request parameters and all there values for cases where + * request parameters have multiple values. + * + * @author Rossen Stoyanchev + * @see RequestParamMethodArgumentResolver + */ +public class RequestParamMapMethodArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + RequestParam requestParam = parameter.getParameterAnnotation(RequestParam.class); + if (requestParam != null) { + if (Map.class.isAssignableFrom(parameter.getParameterType())) { + return !StringUtils.hasText(requestParam.name()); + } + } + return false; + } + + @Override + public Mono resolveArgument(MethodParameter parameter, ModelMap model, ServerWebExchange exchange) { + Class paramType = parameter.getParameterType(); + MultiValueMap queryParams = exchange.getRequest().getQueryParams(); + if (MultiValueMap.class.isAssignableFrom(paramType)) { + return Mono.just(queryParams); + } + else { + return Mono.just(queryParams.toSingleValueMap()); + } + } +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestParamMethodArgumentResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestParamMethodArgumentResolver.java new file mode 100644 index 0000000000..b55bb3c1a4 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestParamMethodArgumentResolver.java @@ -0,0 +1,125 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import java.util.List; +import java.util.Map; + +import reactor.core.publisher.Mono; + +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.core.MethodParameter; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.converter.Converter; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ValueConstants; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.ServerWebInputException; + +/** + * Resolver for method arguments annotated with @{@link RequestParam}. + * + *

This resolver can also be created in default resolution mode in which + * simple types (int, long, etc.) not annotated with @{@link RequestParam} are + * also treated as request parameters with the parameter name derived from the + * argument name. + * + *

If the method parameter type is {@link Map}, the name specified in the + * annotation is used to resolve the request parameter String value. The value is + * then converted to a {@link Map} via type conversion assuming a suitable + * {@link Converter} has been registered. Or if a request parameter name is not + * specified the {@link RequestParamMapMethodArgumentResolver} is used instead + * to provide access to all request parameters in the form of a map. + * + * @author Rossen Stoyanchev + * @see RequestParamMapMethodArgumentResolver + */ +public class RequestParamMethodArgumentResolver extends AbstractNamedValueMethodArgumentResolver { + + private final boolean useDefaultResolution; + + + /** + * @param conversionService for type conversion (to be replaced with WebDataBinder) + * @param beanFactory a bean factory used for resolving ${...} placeholder + * and #{...} SpEL expressions in default values, or {@code null} if default + * values are not expected to contain expressions + * @param useDefaultResolution in default resolution mode a method argument + * that is a simple type, as defined in {@link BeanUtils#isSimpleProperty}, + * is treated as a request parameter even if it isn't annotated, the + * request parameter name is derived from the method parameter name. + */ + public RequestParamMethodArgumentResolver(ConversionService conversionService, + ConfigurableBeanFactory beanFactory, boolean useDefaultResolution) { + + super(conversionService, beanFactory); + this.useDefaultResolution = useDefaultResolution; + } + + + @Override + public boolean supportsParameter(MethodParameter parameter) { + if (parameter.hasParameterAnnotation(RequestParam.class)) { + if (Map.class.isAssignableFrom(parameter.nestedIfOptional().getNestedParameterType())) { + String paramName = parameter.getParameterAnnotation(RequestParam.class).name(); + return StringUtils.hasText(paramName); + } + else { + return true; + } + } + return (this.useDefaultResolution && BeanUtils.isSimpleProperty(parameter.getNestedParameterType())); + } + + @Override + protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) { + RequestParam ann = parameter.getParameterAnnotation(RequestParam.class); + return (ann != null ? new RequestParamNamedValueInfo(ann) : new RequestParamNamedValueInfo()); + } + + @Override + protected Mono resolveName(String name, MethodParameter parameter, ServerWebExchange exchange) { + List paramValues = exchange.getRequest().getQueryParams().get(name); + Object result = null; + if (paramValues != null) { + result = (paramValues.size() == 1 ? paramValues.get(0) : paramValues); + } + return Mono.justOrEmpty(result); + } + + @Override + protected void handleMissingValue(String name, MethodParameter parameter, ServerWebExchange exchange) { + String type = parameter.getNestedParameterType().getSimpleName(); + String reason = "Required " + type + " parameter '" + name + "' is not present"; + throw new ServerWebInputException(reason, parameter); + } + + + private static class RequestParamNamedValueInfo extends NamedValueInfo { + + public RequestParamNamedValueInfo() { + super("", false, ValueConstants.DEFAULT_NONE); + } + + public RequestParamNamedValueInfo(RequestParam annotation) { + super(annotation.name(), annotation.required(), annotation.defaultValue()); + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/ResponseBodyResultHandler.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/ResponseBodyResultHandler.java new file mode 100644 index 0000000000..54cbbcc85e --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/ResponseBodyResultHandler.java @@ -0,0 +1,121 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import java.util.List; + +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.core.ResolvableType; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.core.convert.ConversionService; +import org.springframework.http.HttpEntity; +import org.springframework.http.converter.reactive.HttpMessageConverter; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.reactive.HandlerResult; +import org.springframework.web.reactive.HandlerResultHandler; +import org.springframework.web.reactive.accept.HeaderContentTypeResolver; +import org.springframework.web.reactive.accept.RequestedContentTypeResolver; +import org.springframework.web.server.ServerWebExchange; + + +/** + * {@code HandlerResultHandler} that handles return values from methods annotated + * with {@code @ResponseBody} writing to the body of the request or response with + * an {@link HttpMessageConverter}. + * + *

By default the order for this result handler is set to 100. As it detects + * the presence of {@code @ResponseBody} it should be ordered after result + * handlers that look for a specific return type. Note however that this handler + * does recognize and explicitly ignores the {@code ResponseEntity} return type. + * + * @author Rossen Stoyanchev + * @author Stephane Maldini + * @author Sebastien Deleuze + * @author Arjen Poutsma + */ +public class ResponseBodyResultHandler extends AbstractMessageConverterResultHandler + implements HandlerResultHandler { + + /** + * Constructor with message converters and a {@code ConversionService} only + * and creating a {@link HeaderContentTypeResolver}, i.e. using Accept header + * to determine the requested content type. + * + * @param converters converters for writing the response body with + * @param conversionService for converting to Flux and Mono from other reactive types + */ + public ResponseBodyResultHandler(List> converters, + ConversionService conversionService) { + + this(converters, conversionService, new HeaderContentTypeResolver()); + } + + /** + * Constructor with message converters, a {@code ConversionService}, and a + * {@code RequestedContentTypeResolver}. + * + * @param converters converters for writing the response body with + * @param conversionService for converting other reactive types (e.g. + * rx.Observable, rx.Single, etc.) to Flux or Mono + * @param contentTypeResolver for resolving the requested content type + */ + public ResponseBodyResultHandler(List> converters, + ConversionService conversionService, RequestedContentTypeResolver contentTypeResolver) { + + super(converters, conversionService, contentTypeResolver); + setOrder(100); + } + + + @Override + public boolean supports(HandlerResult result) { + ResolvableType returnType = result.getReturnType(); + MethodParameter parameter = result.getReturnTypeSource(); + return hasResponseBodyAnnotation(parameter) && !isHttpEntityType(returnType); + } + + private boolean hasResponseBodyAnnotation(MethodParameter parameter) { + Class containingClass = parameter.getContainingClass(); + return (AnnotationUtils.findAnnotation(containingClass, ResponseBody.class) != null || + parameter.getMethodAnnotation(ResponseBody.class) != null); + } + + private boolean isHttpEntityType(ResolvableType returnType) { + if (HttpEntity.class.isAssignableFrom(returnType.getRawClass())) { + return true; + } + else if (getConversionService().canConvert(returnType.getRawClass(), Mono.class)) { + ResolvableType genericType = returnType.getGeneric(0); + if (HttpEntity.class.isAssignableFrom(genericType.getRawClass())) { + return true; + } + } + return false; + } + + + @Override + public Mono handleResult(ServerWebExchange exchange, HandlerResult result) { + Object body = result.getReturnValue().orElse(null); + ResolvableType bodyType = result.getReturnType(); + MethodParameter bodyTypeParameter = result.getReturnTypeSource(); + return writeBody(exchange, body, bodyType, bodyTypeParameter); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandler.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandler.java new file mode 100644 index 0000000000..2079db0bd5 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandler.java @@ -0,0 +1,147 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import java.util.List; +import java.util.Optional; + +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.core.ResolvableType; +import org.springframework.core.convert.ConversionService; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.reactive.HttpMessageConverter; +import org.springframework.util.Assert; +import org.springframework.web.reactive.HandlerResult; +import org.springframework.web.reactive.HandlerResultHandler; +import org.springframework.web.reactive.accept.HeaderContentTypeResolver; +import org.springframework.web.reactive.accept.RequestedContentTypeResolver; +import org.springframework.web.server.ServerWebExchange; + +/** + * Handles {@link HttpEntity} and {@link ResponseEntity} return values. + * + *

By default the order for this result handler is set to 0. It is generally + * safe to place it early in the order as it looks for a concrete return type. + * + * @author Rossen Stoyanchev + */ +public class ResponseEntityResultHandler extends AbstractMessageConverterResultHandler + implements HandlerResultHandler { + + /** + * Constructor with message converters and a {@code ConversionService} only + * and creating a {@link HeaderContentTypeResolver}, i.e. using Accept header + * to determine the requested content type. + * + * @param converters converters for writing the response body with + * @param conversionService for converting to Flux and Mono from other reactive types + */ + public ResponseEntityResultHandler(List> converters, + ConversionService conversionService) { + + this(converters, conversionService, new HeaderContentTypeResolver()); + } + + /** + * Constructor with message converters, a {@code ConversionService}, and a + * {@code RequestedContentTypeResolver}. + * + * @param converters converters for writing the response body with + * @param conversionService for converting other reactive types (e.g. + * rx.Observable, rx.Single, etc.) to Flux or Mono + * @param contentTypeResolver for resolving the requested content type + */ + public ResponseEntityResultHandler(List> converters, + ConversionService conversionService, RequestedContentTypeResolver contentTypeResolver) { + + super(converters, conversionService, contentTypeResolver); + setOrder(0); + } + + + @Override + public boolean supports(HandlerResult result) { + ResolvableType returnType = result.getReturnType(); + if (isSupportedType(returnType)) { + return true; + } + else if (getConversionService().canConvert(returnType.getRawClass(), Mono.class)) { + ResolvableType genericType = result.getReturnType().getGeneric(0); + return isSupportedType(genericType); + } + return false; + } + + private boolean isSupportedType(ResolvableType returnType) { + Class clazz = returnType.getRawClass(); + return (HttpEntity.class.isAssignableFrom(clazz) && !RequestEntity.class.isAssignableFrom(clazz)); + } + + + @Override + public Mono handleResult(ServerWebExchange exchange, HandlerResult result) { + + ResolvableType returnType = result.getReturnType(); + + ResolvableType bodyType; + MethodParameter bodyTypeParameter; + + Mono returnValueMono; + Optional optional = result.getReturnValue(); + + if (optional.isPresent() && getConversionService().canConvert(returnType.getRawClass(), Mono.class)) { + returnValueMono = getConversionService().convert(optional.get(), Mono.class); + bodyType = returnType.getGeneric(0, 0); + bodyTypeParameter = new MethodParameter(result.getReturnTypeSource()); + bodyTypeParameter.increaseNestingLevel(); + bodyTypeParameter.increaseNestingLevel(); + } + else { + returnValueMono = Mono.justOrEmpty(optional); + bodyType = returnType.getGeneric(0); + bodyTypeParameter = new MethodParameter(result.getReturnTypeSource()); + bodyTypeParameter.increaseNestingLevel(); + } + + return returnValueMono.then(returnValue -> { + + Assert.isInstanceOf(HttpEntity.class, returnValue); + HttpEntity httpEntity = (HttpEntity) returnValue; + + if (httpEntity instanceof ResponseEntity) { + ResponseEntity responseEntity = (ResponseEntity) httpEntity; + exchange.getResponse().setStatusCode(responseEntity.getStatusCode()); + } + + HttpHeaders entityHeaders = httpEntity.getHeaders(); + HttpHeaders responseHeaders = exchange.getResponse().getHeaders(); + + if (!entityHeaders.isEmpty()) { + entityHeaders.entrySet().stream() + .filter(entry -> !responseHeaders.containsKey(entry.getKey())) + .forEach(entry -> responseHeaders.put(entry.getKey(), entry.getValue())); + } + + return writeBody(exchange, httpEntity.getBody(), bodyType, bodyTypeParameter); + }); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/SessionAttributeMethodArgumentResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/SessionAttributeMethodArgumentResolver.java new file mode 100644 index 0000000000..a48b3ddb03 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/SessionAttributeMethodArgumentResolver.java @@ -0,0 +1,70 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import java.util.Optional; + +import reactor.core.publisher.Mono; + +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.core.MethodParameter; +import org.springframework.core.convert.ConversionService; +import org.springframework.web.bind.annotation.SessionAttribute; +import org.springframework.web.bind.annotation.ValueConstants; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.ServerWebInputException; + +/** + * Resolves method arguments annotated with an @{@link SessionAttribute}. + * + * @author Rossen Stoyanchev + * @see RequestAttributeMethodArgumentResolver + */ +public class SessionAttributeMethodArgumentResolver extends AbstractNamedValueMethodArgumentResolver { + + + public SessionAttributeMethodArgumentResolver(ConversionService conversionService, + ConfigurableBeanFactory beanFactory) { + + super(conversionService, beanFactory); + } + + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(SessionAttribute.class); + } + + @Override + protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) { + SessionAttribute annot = parameter.getParameterAnnotation(SessionAttribute.class); + return new NamedValueInfo(annot.name(), annot.required(), ValueConstants.DEFAULT_NONE); + } + + @Override + protected Mono resolveName(String name, MethodParameter parameter, ServerWebExchange exchange){ + return exchange.getSession().map(session -> session.getAttribute(name)) + .filter(Optional::isPresent).map(Optional::get); + } + + @Override + protected void handleMissingValue(String name, MethodParameter parameter) { + String type = parameter.getNestedParameterType().getSimpleName(); + String reason = "Missing session attribute '" + name + "' of type " + type; + throw new ServerWebInputException(reason, parameter); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/package-info.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/package-info.java new file mode 100644 index 0000000000..eee90dcffd --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/package-info.java @@ -0,0 +1,4 @@ +/** + * Infrastructure for annotation-based handler method processing. + */ +package org.springframework.web.reactive.result.method.annotation; diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/package-info.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/package-info.java new file mode 100644 index 0000000000..9761f929e6 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/package-info.java @@ -0,0 +1,4 @@ +/** + * Infrastructure for handler method processing. + */ +package org.springframework.web.reactive.result.method; diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/package-info.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/package-info.java new file mode 100644 index 0000000000..289c322a51 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/package-info.java @@ -0,0 +1,6 @@ +/** + * Support for various programming model styles including the invocation of + * different types of handlers (e.g. annotated controllers, simple WebHandler, + * etc) as well as result handling (@ResponseBody, view resolution, etc). + */ +package org.springframework.web.reactive.result; diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/AbstractUrlBasedView.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/AbstractUrlBasedView.java new file mode 100644 index 0000000000..6c582bebfd --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/AbstractUrlBasedView.java @@ -0,0 +1,85 @@ +/* + * Copyright 2002-2016 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.reactive.result.view; + +import java.util.Locale; + +import org.springframework.beans.factory.InitializingBean; + +/** + * Abstract base class for URL-based views. Provides a consistent way of + * holding the URL that a View wraps, in the form of a "url" bean property. + * + * @author Rossen Stoyanchev + */ +public abstract class AbstractUrlBasedView extends AbstractView implements InitializingBean { + + private String url; + + + /** + * Constructor for use as a bean. + */ + protected AbstractUrlBasedView() { + } + + /** + * Create a new AbstractUrlBasedView with the given URL. + */ + protected AbstractUrlBasedView(String url) { + this.url = url; + } + + + /** + * Set the URL of the resource that this view wraps. + * The URL must be appropriate for the concrete View implementation. + */ + public void setUrl(String url) { + this.url = url; + } + + /** + * Return the URL of the resource that this view wraps. + */ + public String getUrl() { + return this.url; + } + + + @Override + public void afterPropertiesSet() throws Exception { + if (getUrl() == null) { + throw new IllegalArgumentException("Property 'url' is required"); + } + } + + /** + * Check whether the resource for the configured URL actually exists. + * @param locale the desired Locale that we're looking for + * @return {@code false} if the resource exists + * {@code false} if we know that it does not exist + * @throws Exception if the resource exists but is invalid (e.g. could not be parsed) + */ + public abstract boolean checkResourceExists(Locale locale) throws Exception; + + + @Override + public String toString() { + return super.toString() + "; URL [" + getUrl() + "]"; + } + +} \ No newline at end of file diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/AbstractView.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/AbstractView.java new file mode 100644 index 0000000000..b56f721a59 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/AbstractView.java @@ -0,0 +1,143 @@ +/* + * Copyright 2002-2016 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.reactive.result.view; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import reactor.core.publisher.Mono; + +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.http.MediaType; +import org.springframework.ui.ModelMap; +import org.springframework.util.Assert; +import org.springframework.web.reactive.HandlerResult; +import org.springframework.web.server.ServerWebExchange; + +/** + * + * @author Rossen Stoyanchev + */ +public abstract class AbstractView implements View, ApplicationContextAware { + + /** Logger that is available to subclasses */ + protected final Log logger = LogFactory.getLog(getClass()); + + + private final List mediaTypes = new ArrayList<>(4); + + private ApplicationContext applicationContext; + + + public AbstractView() { + this.mediaTypes.add(ViewResolverSupport.DEFAULT_CONTENT_TYPE); + } + + + /** + * Set the supported media types for this view. + * Default is "text/html;charset=UTF-8". + */ + public void setSupportedMediaTypes(List supportedMediaTypes) { + Assert.notEmpty(supportedMediaTypes, "'supportedMediaTypes' is required."); + this.mediaTypes.clear(); + if (supportedMediaTypes != null) { + this.mediaTypes.addAll(supportedMediaTypes); + } + } + + /** + * Return the configured media types supported by this view. + */ + @Override + public List getSupportedMediaTypes() { + return this.mediaTypes; + } + + @Override + public void setApplicationContext(ApplicationContext applicationContext) { + this.applicationContext = applicationContext; + } + + public ApplicationContext getApplicationContext() { + return applicationContext; + } + + + /** + * Prepare the model to render. + * @param result the result from handler execution + * @param contentType the content type selected to render with which should + * match one of the {@link #getSupportedMediaTypes() supported media types}. + * @param exchange the current exchange + * @return {@code Mono} to represent when and if rendering succeeds + */ + @Override + public Mono render(HandlerResult result, MediaType contentType, + ServerWebExchange exchange) { + + if (logger.isTraceEnabled()) { + logger.trace("Rendering view with model " + result.getModel()); + } + + if (contentType != null) { + exchange.getResponse().getHeaders().setContentType(contentType); + } + + Map mergedModel = getModelAttributes(result, exchange); + return renderInternal(mergedModel, exchange); + } + + /** + * Prepare the model to use for rendering. + *

The default implementation creates a combined output Map that includes + * model as well as static attributes with the former taking precedence. + */ + protected Map getModelAttributes(HandlerResult result, ServerWebExchange exchange) { + ModelMap model = result.getModel(); + int size = (model != null ? model.size() : 0); + + Map attributes = new LinkedHashMap<>(size); + if (model != null) { + attributes.putAll(model); + } + + return attributes; + } + + /** + * Subclasses must implement this method to actually render the view. + * @param renderAttributes combined output Map (never {@code null}), + * with dynamic values taking precedence over static attributes + * @param exchange current exchange + * @return {@code Mono} to represent when and if rendering succeeds + */ + protected abstract Mono renderInternal(Map renderAttributes, + ServerWebExchange exchange); + + + @Override + public String toString() { + return getClass().getName(); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/HttpMessageConverterView.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/HttpMessageConverterView.java new file mode 100644 index 0000000000..e0ecd8df66 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/HttpMessageConverterView.java @@ -0,0 +1,171 @@ +/* + * Copyright 2002-2016 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.reactive.result.view; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.reactivestreams.Publisher; +import reactor.core.publisher.Mono; + +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.Encoder; +import org.springframework.http.MediaType; +import org.springframework.http.converter.reactive.CodecHttpMessageConverter; +import org.springframework.http.converter.reactive.HttpMessageConverter; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.ui.ModelMap; +import org.springframework.util.Assert; +import org.springframework.web.reactive.HandlerResult; +import org.springframework.web.server.ServerWebExchange; + + +/** + * A {@link View} that delegates to an {@link HttpMessageConverter}. + * + * @author Rossen Stoyanchev + */ +public class HttpMessageConverterView implements View { + + private final HttpMessageConverter converter; + + private final Set modelKeys = new HashSet<>(4); + + private final List mediaTypes; + + + /** + * Create a {@code View} with the given {@code Encoder}. + * Internally this creates + * {@link CodecHttpMessageConverter#CodecHttpMessageConverter(Encoder) + * CodecHttpMessageConverter(Encoder)}. + */ + public HttpMessageConverterView(Encoder encoder) { + this(new CodecHttpMessageConverter<>(encoder)); + } + + /** + * Create a View that delegates to the given message converter. + */ + public HttpMessageConverterView(HttpMessageConverter converter) { + Assert.notNull(converter, "'converter' is required."); + this.converter = converter; + this.mediaTypes = converter.getWritableMediaTypes(); + } + + + /** + * Return the configured message converter. + */ + public HttpMessageConverter getConverter() { + return this.converter; + } + + /** + * By default model attributes are filtered with + * {@link HttpMessageConverter#canWrite} to find the ones that can be + * rendered. Use this property to further narrow the list and consider only + * attribute(s) under specific model key(s). + *

If more than one matching attribute is found, than a Map is rendered, + * or if the {@code Encoder} does not support rendering a {@code Map} then + * an exception is raised. + */ + public void setModelKeys(Set modelKeys) { + this.modelKeys.clear(); + if (modelKeys != null) { + this.modelKeys.addAll(modelKeys); + } + } + + /** + * Return the configured model keys. + */ + public final Set getModelKeys() { + return this.modelKeys; + } + + @Override + public List getSupportedMediaTypes() { + return this.mediaTypes; + } + + @Override + public Mono render(HandlerResult result, MediaType contentType, ServerWebExchange exchange) { + Object value = extractObjectToRender(result); + return applyConverter(value, contentType, exchange); + } + + protected Object extractObjectToRender(HandlerResult result) { + ModelMap model = result.getModel(); + Map map = new HashMap<>(model.size()); + for (Map.Entry entry : model.entrySet()) { + if (isEligibleAttribute(entry.getKey(), entry.getValue())) { + map.put(entry.getKey(), entry.getValue()); + } + } + if (map.isEmpty()) { + return null; + } + else if (map.size() == 1) { + return map.values().iterator().next(); + } + else if (getConverter().canWrite(ResolvableType.forClass(Map.class), null)) { + return map; + } + else { + throw new IllegalStateException( + "Multiple matching attributes found: " + map + ". " + + "However Map rendering is not supported by " + getConverter()); + } + } + + /** + * Whether the given model attribute key-value pair is eligible for encoding. + *

The default implementation checks against the configured + * {@link #setModelKeys model keys} and whether the Encoder supports the + * value type. + */ + protected boolean isEligibleAttribute(String attributeName, Object attributeValue) { + ResolvableType type = ResolvableType.forClass(attributeValue.getClass()); + if (getModelKeys().isEmpty()) { + return getConverter().canWrite(type, null); + } + if (getModelKeys().contains(attributeName)) { + if (getConverter().canWrite(type, null)) { + return true; + } + throw new IllegalStateException( + "Model object [" + attributeValue + "] retrieved via key " + + "[" + attributeName + "] is not supported by " + getConverter()); + } + return false; + } + + @SuppressWarnings("unchecked") + private Mono applyConverter(Object value, MediaType contentType, ServerWebExchange exchange) { + if (value == null) { + return Mono.empty(); + } + Publisher stream = Mono.just((T) value); + ResolvableType type = ResolvableType.forClass(value.getClass()); + ServerHttpResponse response = exchange.getResponse(); + return ((HttpMessageConverter) getConverter()).write(stream, type, contentType, response); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/UrlBasedViewResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/UrlBasedViewResolver.java new file mode 100644 index 0000000000..510886fdac --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/UrlBasedViewResolver.java @@ -0,0 +1,204 @@ +/* + * Copyright 2002-2016 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.reactive.result.view; + +import java.util.Locale; + +import reactor.core.publisher.Mono; + +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.util.PatternMatchUtils; + + +/** + * A {@link ViewResolver} that allow direct resolution of symbolic view names + * to URLs without explicit mapping definition. This is useful if symbolic names + * match the names of view resources in a straightforward manner (i.e. the + * symbolic name is the unique part of the resource's filename), without the need + * for a dedicated mapping to be defined for each view. + * + *

Supports {@link AbstractUrlBasedView} subclasses like + * {@link org.springframework.web.reactive.result.view.freemarker.FreeMarkerView}. + * The view class for all views generated by this resolver can be specified + * via the "viewClass" property. + * + *

View names can either be resource URLs themselves, or get augmented by a + * specified prefix and/or suffix. Exporting an attribute that holds the + * RequestContext to all views is explicitly supported. + * + *

Example: prefix="templates/", suffix=".ftl", viewname="test" -> + * "templates/test.ftl" + * + *

As a special feature, redirect URLs can be specified via the "redirect:" + * prefix. E.g.: "redirect:myAction" will trigger a redirect to the given + * URL, rather than resolution as standard view name. This is typically used + * for redirecting to a controller URL after finishing a form workflow. + * + *

Note: This class does not support localized resolution, i.e. resolving + * a symbolic view name to different resources depending on the current locale. + * * @author Rossen Stoyanchev + */ +public class UrlBasedViewResolver extends ViewResolverSupport implements ViewResolver, InitializingBean { + + private Class viewClass; + + private String prefix = ""; + + private String suffix = ""; + + private String[] viewNames; + + + /** + * Set the view class to instantiate through {@link #createUrlBasedView(String)}. + * @param viewClass a class that is assignable to the required view class + * which by default is AbstractUrlBasedView. + */ + public void setViewClass(Class viewClass) { + if (viewClass == null || !requiredViewClass().isAssignableFrom(viewClass)) { + String name = (viewClass != null ? viewClass.getName() : null); + throw new IllegalArgumentException("Given view class [" + name + "] " + + "is not of type [" + requiredViewClass().getName() + "]"); + } + this.viewClass = viewClass; + } + + /** + * Return the view class to be used to create views. + */ + protected Class getViewClass() { + return this.viewClass; + } + + /** + * Return the required type of view for this resolver. + * This implementation returns {@link AbstractUrlBasedView}. + * @see AbstractUrlBasedView + */ + protected Class requiredViewClass() { + return AbstractUrlBasedView.class; + } + + /** + * Set the prefix that gets prepended to view names when building a URL. + */ + public void setPrefix(String prefix) { + this.prefix = (prefix != null ? prefix : ""); + } + + /** + * Return the prefix that gets prepended to view names when building a URL. + */ + protected String getPrefix() { + return this.prefix; + } + + /** + * Set the suffix that gets appended to view names when building a URL. + */ + public void setSuffix(String suffix) { + this.suffix = (suffix != null ? suffix : ""); + } + + /** + * Return the suffix that gets appended to view names when building a URL. + */ + protected String getSuffix() { + return this.suffix; + } + + /** + * Set the view names (or name patterns) that can be handled by this + * {@link ViewResolver}. View names can contain simple wildcards such that + * 'my*', '*Report' and '*Repo*' will all match the view name 'myReport'. + * @see #canHandle + */ + public void setViewNames(String... viewNames) { + this.viewNames = viewNames; + } + + /** + * Return the view names (or name patterns) that can be handled by this + * {@link ViewResolver}. + */ + protected String[] getViewNames() { + return this.viewNames; + } + + + @Override + public void afterPropertiesSet() throws Exception { + if (getViewClass() == null) { + throw new IllegalArgumentException("Property 'viewClass' is required"); + } + } + + + @Override + public Mono resolveViewName(String viewName, Locale locale) { + if (!canHandle(viewName, locale)) { + return Mono.empty(); + } + AbstractUrlBasedView urlBasedView = createUrlBasedView(viewName); + View view = applyLifecycleMethods(viewName, urlBasedView); + try { + return (urlBasedView.checkResourceExists(locale) ? Mono.just(view) : Mono.empty()); + } + catch (Exception ex) { + return Mono.error(ex); + } + } + + /** + * Indicates whether or not this {@link ViewResolver} can handle the + * supplied view name. If not, an empty result is returned. The default + * implementation checks against the configured {@link #setViewNames + * view names}. + * @param viewName the name of the view to retrieve + * @param locale the Locale to retrieve the view for + * @return whether this resolver applies to the specified view + * @see org.springframework.util.PatternMatchUtils#simpleMatch(String, String) + */ + protected boolean canHandle(String viewName, Locale locale) { + String[] viewNames = getViewNames(); + return (viewNames == null || PatternMatchUtils.simpleMatch(viewNames, viewName)); + } + + /** + * Creates a new View instance of the specified view class and configures it. + * Does not perform any lookup for pre-defined View instances. + *

Spring lifecycle methods as defined by the bean container do not have to + * be called here; those will be applied by the {@code loadView} method + * after this method returns. + *

Subclasses will typically call {@code super.buildView(viewName)} + * first, before setting further properties themselves. {@code loadView} + * will then apply Spring lifecycle methods at the end of this process. + * @param viewName the name of the view to build + * @return the View instance + */ + protected AbstractUrlBasedView createUrlBasedView(String viewName) { + AbstractUrlBasedView view = (AbstractUrlBasedView) BeanUtils.instantiateClass(getViewClass()); + view.setSupportedMediaTypes(getSupportedMediaTypes()); + view.setUrl(getPrefix() + viewName + getSuffix()); + return view; + } + + private View applyLifecycleMethods(String viewName, AbstractView view) { + return (View) getApplicationContext().getAutowireCapableBeanFactory().initializeBean(view, viewName); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/View.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/View.java new file mode 100644 index 0000000000..3b9ee66f56 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/View.java @@ -0,0 +1,58 @@ +/* + * Copyright 2002-2016 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.reactive.result.view; + +import java.util.List; + +import reactor.core.publisher.Mono; + +import org.springframework.http.MediaType; +import org.springframework.web.reactive.HandlerResult; +import org.springframework.web.server.ServerWebExchange; + +/** + * Contract to render {@link HandlerResult} to the HTTP response. + * + *

In contrast to an {@link org.springframework.core.codec.Encoder Encoder} + * which is a singleton and encodes any object of a given type, a {@code View} + * is typically selected by name and resolved using a {@link ViewResolver} + * which may for example match it to an HTML template. Furthermore a {@code View} + * may render based on multiple attributes contained in the model. + * + *

A {@code View} can also choose to select an attribute from the model use + * any existing {@code Encoder} to render alternate media types. + * + * @author Rossen Stoyanchev + */ +public interface View { + + /** + * Return the list of media types this View supports, or an empty list. + */ + List getSupportedMediaTypes(); + + /** + * Render the view based on the given {@link HandlerResult}. Implementations + * can access and use the model or only a specific attribute in it. + * @param result the result from handler execution + * @param contentType the content type selected to render with which should + * match one of the {@link #getSupportedMediaTypes() supported media types}. + * @param exchange the current exchange + * @return {@code Mono} to represent when and if rendering succeeds + */ + Mono render(HandlerResult result, MediaType contentType, ServerWebExchange exchange); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandler.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandler.java new file mode 100644 index 0000000000..f46c142128 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandler.java @@ -0,0 +1,327 @@ +/* + * Copyright 2002-2016 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.reactive.result.view; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.beans.BeanUtils; +import org.springframework.core.Conventions; +import org.springframework.core.GenericTypeResolver; +import org.springframework.core.MethodParameter; +import org.springframework.core.Ordered; +import org.springframework.core.ResolvableType; +import org.springframework.core.annotation.AnnotationAwareOrderComparator; +import org.springframework.core.convert.ConversionService; +import org.springframework.http.MediaType; +import org.springframework.ui.Model; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.reactive.HandlerResult; +import org.springframework.web.reactive.HandlerResultHandler; +import org.springframework.web.reactive.accept.HeaderContentTypeResolver; +import org.springframework.web.reactive.accept.RequestedContentTypeResolver; +import org.springframework.web.reactive.result.ContentNegotiatingResultHandlerSupport; +import org.springframework.web.server.NotAcceptableStatusException; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.util.HttpRequestPathHelper; + +/** + * {@code HandlerResultHandler} that encapsulates the view resolution algorithm + * supporting the following return types: + *

    + *
  • String-based view name + *
  • Reference to a {@link View} + *
  • {@link Model} + *
  • {@link Map} + *
  • Return types annotated with {@code @ModelAttribute} + *
  • {@link BeanUtils#isSimpleProperty Non-simple} return types are + * treated as a model attribute + *
+ * + *

A String-based view name is resolved through the configured + * {@link ViewResolver} instances into a {@link View} to use for rendering. + * If a view is left unspecified (e.g. by returning {@code null} or a + * model-related return value), a default view name is selected. + * + *

By default this resolver is ordered at {@link Ordered#LOWEST_PRECEDENCE} + * and generally needs to be late in the order since it interprets any String + * return value as a view name while others may interpret the same otherwise + * based on annotations (e.g. for {@code @ResponseBody}). + * + * @author Rossen Stoyanchev + */ +public class ViewResolutionResultHandler extends ContentNegotiatingResultHandlerSupport + implements HandlerResultHandler, Ordered { + + private final List viewResolvers = new ArrayList<>(4); + + private final List defaultViews = new ArrayList<>(4); + + private final HttpRequestPathHelper pathHelper = new HttpRequestPathHelper(); + + + /** + * Constructor with {@code ViewResolver}s and a {@code ConversionService} only + * and creating a {@link HeaderContentTypeResolver}, i.e. using Accept header + * to determine the requested content type. + * @param resolvers the resolver to use + * @param conversionService for converting other reactive types (e.g. rx.Single) to Mono + */ + public ViewResolutionResultHandler(List resolvers, ConversionService conversionService) { + this(resolvers, conversionService, new HeaderContentTypeResolver()); + } + + /** + * Constructor with {@code ViewResolver}s tand a {@code ConversionService}. + * @param resolvers the resolver to use + * @param conversionService for converting other reactive types (e.g. rx.Single) to Mono + * @param contentTypeResolver for resolving the requested content type + */ + public ViewResolutionResultHandler(List resolvers, ConversionService conversionService, + RequestedContentTypeResolver contentTypeResolver) { + + super(conversionService, contentTypeResolver); + this.viewResolvers.addAll(resolvers); + AnnotationAwareOrderComparator.sort(this.viewResolvers); + } + + + /** + * Return a read-only list of view resolvers. + */ + public List getViewResolvers() { + return Collections.unmodifiableList(this.viewResolvers); + } + + /** + * Set the default views to consider always when resolving view names and + * trying to satisfy the best matching content type. + */ + public void setDefaultViews(List defaultViews) { + this.defaultViews.clear(); + if (defaultViews != null) { + this.defaultViews.addAll(defaultViews); + } + } + + /** + * Return the configured default {@code View}'s. + */ + public List getDefaultViews() { + return this.defaultViews; + } + + @Override + public boolean supports(HandlerResult result) { + Class clazz = result.getReturnType().getRawClass(); + if (hasModelAttributeAnnotation(result)) { + return true; + } + if (isSupportedType(clazz)) { + return true; + } + if (getConversionService().canConvert(clazz, Mono.class)) { + clazz = result.getReturnType().getGeneric(0).getRawClass(); + return isSupportedType(clazz); + } + return false; + } + + private boolean hasModelAttributeAnnotation(HandlerResult result) { + MethodParameter returnType = result.getReturnTypeSource(); + return returnType.hasMethodAnnotation(ModelAttribute.class); + } + + private boolean isSupportedType(Class clazz) { + return (CharSequence.class.isAssignableFrom(clazz) || View.class.isAssignableFrom(clazz) || + Model.class.isAssignableFrom(clazz) || Map.class.isAssignableFrom(clazz) || + !BeanUtils.isSimpleProperty(clazz)); + } + + @Override + public Mono handleResult(ServerWebExchange exchange, HandlerResult result) { + + Mono valueMono; + ResolvableType elementType; + ResolvableType returnType = result.getReturnType(); + + if (getConversionService().canConvert(returnType.getRawClass(), Mono.class)) { + Optional optionalValue = result.getReturnValue(); + if (optionalValue.isPresent()) { + Mono converted = getConversionService().convert(optionalValue.get(), Mono.class); + valueMono = converted.map(o -> o); + } + else { + valueMono = Mono.empty(); + } + elementType = returnType.getGeneric(0); + } + else { + valueMono = Mono.justOrEmpty(result.getReturnValue()); + elementType = returnType; + } + + Mono viewMono; + if (isViewNameOrReference(elementType, result)) { + Mono viewName = getDefaultViewNameMono(exchange, result); + viewMono = valueMono.otherwiseIfEmpty(viewName); + } + else { + viewMono = valueMono.map(value -> updateModel(value, result)) + .defaultIfEmpty(result.getModel()) + .then(model -> getDefaultViewNameMono(exchange, result)); + } + + return viewMono.then(view -> { + if (view instanceof View) { + return ((View) view).render(result, null, exchange); + } + else if (view instanceof CharSequence) { + String viewName = view.toString(); + Locale locale = Locale.getDefault(); // TODO + return resolveAndRender(viewName, locale, result, exchange); + + } + else { + // Should not happen + return Mono.error(new IllegalStateException("Unexpected view type")); + } + }); + } + + private boolean isViewNameOrReference(ResolvableType elementType, HandlerResult result) { + Class clazz = elementType.getRawClass(); + return (View.class.isAssignableFrom(clazz) || + (CharSequence.class.isAssignableFrom(clazz) && !hasModelAttributeAnnotation(result))); + } + + private Mono getDefaultViewNameMono(ServerWebExchange exchange, HandlerResult result) { + String defaultViewName = getDefaultViewName(result, exchange); + if (defaultViewName != null) { + return Mono.just(defaultViewName); + } + else { + return Mono.error(new IllegalStateException( + "Handler [" + result.getHandler() + "] " + + "neither returned a view name nor a View object")); + } + } + + /** + * Translate the given request into a default view name. This is useful when + * the application leaves the view name unspecified. + *

The default implementation strips the leading and trailing slash from + * the as well as any extension and uses that as the view name. + * @return the default view name to use; if {@code null} is returned + * processing will result in an IllegalStateException. + */ + @SuppressWarnings("UnusedParameters") + protected String getDefaultViewName(HandlerResult result, ServerWebExchange exchange) { + String path = this.pathHelper.getLookupPathForRequest(exchange); + if (path.startsWith("/")) { + path = path.substring(1); + } + if (path.endsWith("/")) { + path = path.substring(0, path.length() - 1); + } + return StringUtils.stripFilenameExtension(path); + } + + private Object updateModel(Object value, HandlerResult result) { + if (value instanceof Model) { + result.getModel().addAllAttributes(((Model) value).asMap()); + } + else if (value instanceof Map) { + //noinspection unchecked + result.getModel().addAllAttributes((Map) value); + } + else { + MethodParameter returnType = result.getReturnTypeSource(); + String name = getNameForReturnValue(value, returnType); + result.getModel().addAttribute(name, value); + } + return value; + } + + /** + * Derive the model attribute name for the given return value using one of: + *

    + *
  1. The method {@code ModelAttribute} annotation value + *
  2. The declared return type if it is more specific than {@code Object} + *
  3. The actual return value type + *
+ * @param returnValue the value returned from a method invocation + * @param returnType the return type of the method + * @return the model name, never {@code null} nor empty + */ + private static String getNameForReturnValue(Object returnValue, MethodParameter returnType) { + ModelAttribute annotation = returnType.getMethodAnnotation(ModelAttribute.class); + if (annotation != null && StringUtils.hasText(annotation.value())) { + return annotation.value(); + } + else { + Method method = returnType.getMethod(); + Class containingClass = returnType.getContainingClass(); + Class resolvedType = GenericTypeResolver.resolveReturnType(method, containingClass); + return Conventions.getVariableNameForReturnType(method, resolvedType, returnValue); + } + } + + private Mono resolveAndRender(String viewName, Locale locale, + HandlerResult result, ServerWebExchange exchange) { + + return Flux.fromIterable(getViewResolvers()) + .concatMap(resolver -> resolver.resolveViewName(viewName, locale)) + .switchIfEmpty(Mono.error( + new IllegalStateException( + "Could not resolve view with name '" + viewName + "'."))) + .collectList() + .then(views -> { + views.addAll(getDefaultViews()); + + List producibleTypes = getProducibleMediaTypes(views); + MediaType bestMediaType = selectMediaType(exchange, producibleTypes); + + if (bestMediaType != null) { + for (View view : views) { + for (MediaType supported : view.getSupportedMediaTypes()) { + if (supported.isCompatibleWith(bestMediaType)) { + return view.render(result, bestMediaType, exchange); + } + } + } + } + + return Mono.error(new NotAcceptableStatusException(producibleTypes)); + }); + } + + private List getProducibleMediaTypes(List views) { + List result = new ArrayList<>(); + views.forEach(view -> result.addAll(view.getSupportedMediaTypes())); + return result; + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/ViewResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/ViewResolver.java new file mode 100644 index 0000000000..f275f2bf42 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/ViewResolver.java @@ -0,0 +1,30 @@ +package org.springframework.web.reactive.result.view; + +import java.util.Locale; + +import reactor.core.publisher.Mono; + +/** + * Contract to resolve a view name to a {@link View} instance. The view name may + * correspond to an HTML template or be generated dynamically. + * + *

The process of view resolution is driven through a ViewResolver-based + * {@code HandlerResultHandler} implementation called + * {@link ViewResolutionResultHandler + * ViewResolutionResultHandler}. + * + * @author Rossen Stoyanchev + * @see ViewResolutionResultHandler + + */ +public interface ViewResolver { + + /** + * Resolve the view name to a View instance. + * @param viewName the name of the view to resolve + * @param locale the locale for the request + * @return the resolved view or an empty stream + */ + Mono resolveViewName(String viewName, Locale locale); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/ViewResolverSupport.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/ViewResolverSupport.java new file mode 100644 index 0000000000..7c38a1fc12 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/ViewResolverSupport.java @@ -0,0 +1,95 @@ +/* + * Copyright 2002-2016 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.reactive.result.view; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.core.Ordered; +import org.springframework.http.MediaType; +import org.springframework.util.Assert; + +/** + * Base class for {@code ViewResolver} implementations with shared properties. + * + * @author Rossen Stoyanchev + * @since 4.3 + */ +public abstract class ViewResolverSupport implements ApplicationContextAware, Ordered { + + public static final MediaType DEFAULT_CONTENT_TYPE = MediaType.parseMediaType("text/html;charset=UTF-8"); + + + private List mediaTypes = new ArrayList<>(4); + + private ApplicationContext applicationContext; + + private int order = Integer.MAX_VALUE; + + + public ViewResolverSupport() { + this.mediaTypes.add(DEFAULT_CONTENT_TYPE); + } + + + /** + * Set the supported media types for this view. + * Default is "text/html;charset=UTF-8". + */ + public void setSupportedMediaTypes(List supportedMediaTypes) { + Assert.notEmpty(supportedMediaTypes, "'supportedMediaTypes' is required."); + this.mediaTypes.clear(); + if (supportedMediaTypes != null) { + this.mediaTypes.addAll(supportedMediaTypes); + } + } + + /** + * Return the configured media types supported by this view. + */ + public List getSupportedMediaTypes() { + return this.mediaTypes; + } + + @Override + public void setApplicationContext(ApplicationContext applicationContext) { + this.applicationContext = applicationContext; + } + + public ApplicationContext getApplicationContext() { + return this.applicationContext; + } + + /** + * Set the order in which this {@link ViewResolver} + * is evaluated. + */ + public void setOrder(int order) { + this.order = order; + } + + /** + * Return the order in which this {@link ViewResolver} is evaluated. + */ + @Override + public int getOrder() { + return this.order; + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/freemarker/FreeMarkerConfig.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/freemarker/FreeMarkerConfig.java new file mode 100644 index 0000000000..79653256ba --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/freemarker/FreeMarkerConfig.java @@ -0,0 +1,39 @@ +/* + * Copyright 2002-2016 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.reactive.result.view.freemarker; + +import freemarker.template.Configuration; + +/** + * Interface to be implemented by objects that configure and manage a + * FreeMarker Configuration object in a web environment. Detected and + * used by {@link FreeMarkerView}. + * + * @author Rossen Stoyanchev + */ +public interface FreeMarkerConfig { + + /** + * Return the FreeMarker Configuration object for the current + * web application context. + *

A FreeMarker Configuration object may be used to set FreeMarker + * properties and shared objects, and allows to retrieve templates. + * @return the FreeMarker Configuration + */ + Configuration getConfiguration(); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/freemarker/FreeMarkerConfigurer.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/freemarker/FreeMarkerConfigurer.java new file mode 100644 index 0000000000..b812910282 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/freemarker/FreeMarkerConfigurer.java @@ -0,0 +1,116 @@ +/* + * Copyright 2002-2016 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.reactive.result.view.freemarker; + +import java.io.IOException; +import java.util.List; + +import freemarker.cache.ClassTemplateLoader; +import freemarker.cache.TemplateLoader; +import freemarker.template.Configuration; +import freemarker.template.TemplateException; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.ResourceLoaderAware; +import org.springframework.ui.freemarker.FreeMarkerConfigurationFactory; + +/** + * Configures FreeMarker for web usage via the "configLocation" and/or + * "freemarkerSettings" and/or "templateLoaderPath" properties. + * The simplest way to use this class is to specify just a "templateLoaderPath" + * (e.g. "classpath:templates"); you do not need any further configuration then. + * + *

This bean must be included in the application context of any application + * using {@link FreeMarkerView}. It exists purely to configure FreeMarker. + * It is not meant to be referenced by application components but just internally + * by {@code FreeMarkerView}. Implements {@link FreeMarkerConfig} to be found by + * {@code FreeMarkerView} without depending on the bean name the configurer. + * + *

Note that you can also refer to a pre-configured FreeMarker Configuration + * instance via the "configuration" property. This allows to share a FreeMarker + * Configuration for web and email usage for example. + * + *

TODO: macros + * + *

This configurer registers a template loader for this package, allowing to + * reference the "spring.ftl" macro library contained in this package: + * + *

+ * <#import "/spring.ftl" as spring/>
+ * <@spring.bind "person.age"/>
+ * age is ${spring.status.value}
+ * + * Note: Spring's FreeMarker support requires FreeMarker 2.3 or higher. + * + * @author Rossen Stoyanchev + */ +public class FreeMarkerConfigurer extends FreeMarkerConfigurationFactory + implements FreeMarkerConfig, InitializingBean, ResourceLoaderAware { + + private Configuration configuration; + + + public FreeMarkerConfigurer() { + setDefaultEncoding("UTF-8"); + } + + + /** + * Set a pre-configured Configuration to use for the FreeMarker web config, + * e.g. a shared one for web and email usage. If this is not set, + * FreeMarkerConfigurationFactory's properties (inherited by this class) + * have to be specified. + */ + public void setConfiguration(Configuration configuration) { + this.configuration = configuration; + } + + + /** + * Initialize FreeMarkerConfigurationFactory's Configuration + * if not overridden by a pre-configured FreeMarker Configuation. + *

Sets up a ClassTemplateLoader to use for loading Spring macros. + * @see #createConfiguration + * @see #setConfiguration + */ + @Override + public void afterPropertiesSet() throws IOException, TemplateException { + if (this.configuration == null) { + this.configuration = createConfiguration(); + } + } + + /** + * This implementation registers an additional ClassTemplateLoader + * for the Spring-provided macros, added to the end of the list. + */ + @Override + protected void postProcessTemplateLoaders(List templateLoaders) { + templateLoaders.add(new ClassTemplateLoader(FreeMarkerConfigurer.class, "")); + logger.info("ClassTemplateLoader for Spring macros added to FreeMarker configuration"); + } + + + /** + * Return the Configuration object wrapped by this bean. + */ + @Override + public Configuration getConfiguration() { + return this.configuration; + } + +} \ No newline at end of file diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/freemarker/FreeMarkerView.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/freemarker/FreeMarkerView.java new file mode 100644 index 0000000000..8caaa1c9a0 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/freemarker/FreeMarkerView.java @@ -0,0 +1,221 @@ +/* + * Copyright 2002-2016 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.reactive.result.view.freemarker; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.util.Locale; +import java.util.Map; + +import freemarker.core.ParseException; +import freemarker.template.Configuration; +import freemarker.template.DefaultObjectWrapperBuilder; +import freemarker.template.ObjectWrapper; +import freemarker.template.SimpleHash; +import freemarker.template.Template; +import freemarker.template.Version; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactoryUtils; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.context.ApplicationContextException; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.web.reactive.result.view.AbstractUrlBasedView; +import org.springframework.web.server.ServerWebExchange; + +/** + * A {@code View} implementation that uses the FreeMarker template engine. + * + *

Depends on a single {@link FreeMarkerConfig} object such as + * {@link FreeMarkerConfigurer} being accessible in the application context. + * Alternatively set the FreeMarker configuration can be set directly on this + * class via {@link #setConfiguration}. + * + *

The {@link #setUrl(String) url} property is the location of the FreeMarker + * template relative to the FreeMarkerConfigurer's + * {@link FreeMarkerConfigurer#setTemplateLoaderPath templateLoaderPath}. + * + *

Note: Spring's FreeMarker support requires FreeMarker 2.3 or higher. + * + * @author Rossen Stoyanchev + */ +public class FreeMarkerView extends AbstractUrlBasedView { + + private Configuration configuration; + + private String encoding; + + + /** + * Set the FreeMarker Configuration to be used by this view. + *

Typically this property is not set directly. Instead a single + * {@link FreeMarkerConfig} is expected in the Spring application context + * which is used to obtain the FreeMarker configuration. + */ + public void setConfiguration(Configuration configuration) { + this.configuration = configuration; + } + + /** + * Return the FreeMarker configuration used by this view. + */ + protected Configuration getConfiguration() { + return this.configuration; + } + + /** + * Set the encoding of the FreeMarker template file. + *

By default {@link FreeMarkerConfigurer} sets the default encoding in + * the FreeMarker configuration to "UTF-8". It's recommended to specify the + * encoding in the FreeMarker Configuration rather than per template if all + * your templates share a common encoding. + */ + public void setEncoding(String encoding) { + this.encoding = encoding; + } + + /** + * Return the encoding for the FreeMarker template. + */ + protected String getEncoding() { + return this.encoding; + } + + + @Override + public void afterPropertiesSet() throws Exception { + super.afterPropertiesSet(); + if (getConfiguration() == null) { + FreeMarkerConfig config = autodetectConfiguration(); + setConfiguration(config.getConfiguration()); + } + } + + /** + * Autodetect a {@link FreeMarkerConfig} object via the ApplicationContext. + * @return the Configuration instance to use for FreeMarkerViews + * @throws BeansException if no Configuration instance could be found + * @see #setConfiguration + */ + protected FreeMarkerConfig autodetectConfiguration() throws BeansException { + try { + return BeanFactoryUtils.beanOfTypeIncludingAncestors( + getApplicationContext(), FreeMarkerConfig.class, true, false); + } + catch (NoSuchBeanDefinitionException ex) { + throw new ApplicationContextException( + "Must define a single FreeMarkerConfig bean in this web application context " + + "(may be inherited): FreeMarkerConfigurer is the usual implementation. " + + "This bean may be given any name.", ex); + } + } + + + /** + * Check that the FreeMarker template used for this view exists and is valid. + *

Can be overridden to customize the behavior, for example in case of + * multiple templates to be rendered into a single view. + */ + @Override + public boolean checkResourceExists(Locale locale) throws Exception { + try { + // Check that we can get the template, even if we might subsequently get it again. + getTemplate(locale); + return true; + } + catch (FileNotFoundException ex) { + if (logger.isDebugEnabled()) { + logger.debug("No FreeMarker view found for URL: " + getUrl()); + } + return false; + } + catch (ParseException ex) { + throw new ApplicationContextException( + "Failed to parse FreeMarker template for URL [" + getUrl() + "]", ex); + } + catch (IOException ex) { + throw new ApplicationContextException( + "Could not load FreeMarker template for URL [" + getUrl() + "]", ex); + } + } + + @Override + protected Mono renderInternal(Map renderAttributes, ServerWebExchange exchange) { + // Expose all standard FreeMarker hash models. + SimpleHash freeMarkerModel = getTemplateModel(renderAttributes, exchange); + if (logger.isDebugEnabled()) { + logger.debug("Rendering FreeMarker template [" + getUrl() + "]."); + } + Locale locale = Locale.getDefault(); // TODO + DataBuffer dataBuffer = exchange.getResponse().bufferFactory().allocateBuffer(); + try { + // TODO: pass charset + Writer writer = new OutputStreamWriter(dataBuffer.asOutputStream()); + getTemplate(locale).process(freeMarkerModel, writer); + } + catch (IOException ex) { + String message = "Could not load FreeMarker template for URL [" + getUrl() + "]"; + return Mono.error(new IllegalStateException(message, ex)); + } + catch (Throwable ex) { + return Mono.error(ex); + } + return exchange.getResponse().writeWith(Flux.just(dataBuffer)); + } + + /** + * Build a FreeMarker template model for the given model Map. + *

The default implementation builds a {@link SimpleHash}. + * @param model the model to use for rendering + * @param exchange current exchange + * @return the FreeMarker template model, as a {@link SimpleHash} or subclass thereof + */ + protected SimpleHash getTemplateModel(Map model, ServerWebExchange exchange) { + SimpleHash fmModel = new SimpleHash(getObjectWrapper()); + fmModel.putAll(model); + return fmModel; + } + + /** + * Return the configured FreeMarker {@link ObjectWrapper}, or the + * {@link ObjectWrapper#DEFAULT_WRAPPER default wrapper} if none specified. + * @see freemarker.template.Configuration#getObjectWrapper() + */ + protected ObjectWrapper getObjectWrapper() { + ObjectWrapper ow = getConfiguration().getObjectWrapper(); + Version version = Configuration.DEFAULT_INCOMPATIBLE_IMPROVEMENTS; + return (ow != null ? ow : new DefaultObjectWrapperBuilder(version).build()); + } + + /** + * Retrieve the FreeMarker template for the given locale, + * to be rendering by this view. + *

By default, the template specified by the "url" bean property + * will be retrieved. + * @param locale the current locale + * @return the FreeMarker template to render + */ + protected Template getTemplate(Locale locale) throws IOException { + return (getEncoding() != null ? + getConfiguration().getTemplate(getUrl(), locale, getEncoding()) : + getConfiguration().getTemplate(getUrl(), locale)); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/freemarker/FreeMarkerViewResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/freemarker/FreeMarkerViewResolver.java new file mode 100644 index 0000000000..d8f9d8a97d --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/freemarker/FreeMarkerViewResolver.java @@ -0,0 +1,58 @@ +/* + * Copyright 2002-2016 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.reactive.result.view.freemarker; + +import org.springframework.web.reactive.result.view.UrlBasedViewResolver; + +/** + * A {@code ViewResolver} for resolving {@link FreeMarkerView} instances, i.e. + * FreeMarker templates and custom subclasses of it. + * + *

The view class for all views generated by this resolver can be specified + * via the "viewClass" property. See {@link UrlBasedViewResolver} for details. + * + * @author Rossen Stoyanchev + */public class FreeMarkerViewResolver extends UrlBasedViewResolver { + + + /** + * Simple constructor. + */ + public FreeMarkerViewResolver() { + setViewClass(requiredViewClass()); + } + + /** + * Convenience constructor with a prefix and suffix. + * @param suffix the suffix to prepend view names with + * @param prefix the prefix to prepend view names with + */ + public FreeMarkerViewResolver(String prefix, String suffix) { + setViewClass(requiredViewClass()); + setPrefix(prefix); + setSuffix(suffix); + } + + + /** + * Requires {@link FreeMarkerView}. + */ + @Override + protected Class requiredViewClass() { + return FreeMarkerView.class; + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/package-info.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/package-info.java new file mode 100644 index 0000000000..c789bb2632 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/package-info.java @@ -0,0 +1,4 @@ +/** + * Support for result handling through view resolution. + */ +package org.springframework.web.reactive.result.view; diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/sse/SseEvent.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/sse/SseEvent.java new file mode 100644 index 0000000000..9386e59d57 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/sse/SseEvent.java @@ -0,0 +1,164 @@ +/* + * Copyright 2002-2016 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.reactive.sse; + +import org.springframework.http.MediaType; +import org.springframework.http.codec.SseEventEncoder; + +/** + * Represent a Server-Sent Event. + * + *

{@code Flux} is Spring Web Reactive equivalent to Spring MVC + * {@code SseEmitter} type. It allows to send Server-Sent Events in a reactive way. + * + * @author Sebastien Deleuze + * @see SseEventEncoder + * @see Server-Sent Events W3C recommendation + */ +public class SseEvent { + + private String id; + + private String name; + + private Object data; + + private MediaType mediaType; + + private Long reconnectTime; + + private String comment; + + /** + * Create an empty instance. + */ + public SseEvent() { + } + + /** + * Create an instance with the provided {@code data}. + */ + public SseEvent(Object data) { + this.data = data; + } + + /** + * Create an instance with the provided {@code data} and {@code mediaType}. + */ + public SseEvent(Object data, MediaType mediaType) { + this.data = data; + this.mediaType = mediaType; + } + + /** + * Set the {@code id} SSE field + */ + public void setId(String id) { + this.id = id; + } + + /** + * @see #setId(String) + */ + public String getId() { + return id; + } + + /** + * Set the {@code event} SSE field + */ + public void setName(String name) { + this.name = name; + } + + /** + * @see #setName(String) + */ + public String getName() { + return name; + } + + /** + * Set {@code data} SSE field. If a multiline {@code String} is provided, it will be + * turned into multiple {@code data} field lines by as + * defined in Server-Sent Events W3C recommendation. + * + * If no {@code mediaType} is defined, default {@link SseEventEncoder} will: + * - Turn single line {@code String} to a single {@code data} field + * - Turn multiline line {@code String} to multiple {@code data} fields + * - Serialize other {@code Object} as JSON + * + * @see #setMediaType(MediaType) + */ + public void setData(Object data) { + this.data = data; + } + + /** + * @see #setData(Object) + */ + public Object getData() { + return data; + } + + /** + * Set the {@link MediaType} used to serialize the {@code data}. + * {@link SseEventEncoder} should be configured with the relevant encoder to be + * able to serialize it. + */ + public void setMediaType(MediaType mediaType) { + this.mediaType = mediaType; + } + + /** + * @see #setMediaType(MediaType) + */ + public MediaType getMediaType() { + return this.mediaType; + } + + /** + * Set the {@code retry} SSE field + */ + public void setReconnectTime(Long reconnectTime) { + this.reconnectTime = reconnectTime; + } + + /** + * @see #setReconnectTime(Long) + */ + public Long getReconnectTime() { + return reconnectTime; + } + + /** + * Set SSE comment. If a multiline comment is provided, it will be turned into multiple + * SSE comment lines by {@link SseEventEncoder} as defined in Server-Sent Events W3C + * recommendation. + */ + public void setComment(String comment) { + this.comment = comment; + } + + /** + * @see #setComment(String) + */ + public String getComment() { + return comment; + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/MediaTypeNotSupportedStatusException.java b/spring-web-reactive/src/main/java/org/springframework/web/server/MediaTypeNotSupportedStatusException.java new file mode 100644 index 0000000000..326fbb4395 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/MediaTypeNotSupportedStatusException.java @@ -0,0 +1,59 @@ +/* + * Copyright 2002-2016 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.server; + +import java.util.Collections; +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; + +/** + * Exception for errors that fit response status 415 (unsupported media type). + * + * @author Rossen Stoyanchev + */ +public class MediaTypeNotSupportedStatusException extends ResponseStatusException { + + private final List supportedMediaTypes; + + + /** + * Constructor for when the Content-Type is invalid. + */ + public MediaTypeNotSupportedStatusException(String reason) { + super(HttpStatus.UNSUPPORTED_MEDIA_TYPE, reason); + this.supportedMediaTypes = Collections.emptyList(); + } + + /** + * Constructor for when the Content-Type is not supported. + */ + public MediaTypeNotSupportedStatusException(List supportedMediaTypes) { + super(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "Unsupported media type", null); + this.supportedMediaTypes = Collections.unmodifiableList(supportedMediaTypes); + } + + + /** + * Return the list of supported content types in cases when the Accept + * header is parsed but not supported, or an empty list otherwise. + */ + public List getSupportedMediaTypes() { + return this.supportedMediaTypes; + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/MethodNotAllowedException.java b/spring-web-reactive/src/main/java/org/springframework/web/server/MethodNotAllowedException.java new file mode 100644 index 0000000000..3759483a64 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/MethodNotAllowedException.java @@ -0,0 +1,59 @@ +/* + * Copyright 2002-2016 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.server; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import org.springframework.http.HttpStatus; +import org.springframework.util.Assert; + +/** + * Exception for errors that fit response status 405 (method not allowed). + * + * @author Rossen Stoyanchev + */ +public class MethodNotAllowedException extends ResponseStatusException { + + private String method; + + private Set supportedMethods; + + + public MethodNotAllowedException(String method, Collection supportedMethods) { + super(HttpStatus.METHOD_NOT_ALLOWED, "Request method '" + method + "' not supported"); + Assert.notNull(method, "'method' is required"); + this.method = method; + this.supportedMethods = Collections.unmodifiableSet(new HashSet<>(supportedMethods)); + } + + + /** + * Return the HTTP method for the failed request. + */ + public String getHttpMethod() { + return this.method; + } + + /** + * Return the list of supported HTTP methods. + */ + public Set getSupportedMethods() { + return supportedMethods; + } +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/NotAcceptableStatusException.java b/spring-web-reactive/src/main/java/org/springframework/web/server/NotAcceptableStatusException.java new file mode 100644 index 0000000000..fde57105b4 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/NotAcceptableStatusException.java @@ -0,0 +1,59 @@ +/* + * Copyright 2002-2016 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.server; + +import java.util.Collections; +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; + +/** + * Exception for errors that fit response status 405 (not acceptable). + * + * @author Rossen Stoyanchev + */ +public class NotAcceptableStatusException extends ResponseStatusException { + + private final List supportedMediaTypes; + + + /** + * Constructor for when the requested Content-Type is invalid. + */ + public NotAcceptableStatusException(String reason) { + super(HttpStatus.NOT_ACCEPTABLE, reason); + this.supportedMediaTypes = Collections.emptyList(); + } + + /** + * Constructor for when requested Content-Type is not supported. + */ + public NotAcceptableStatusException(List supportedMediaTypes) { + super(HttpStatus.NOT_ACCEPTABLE, "Could not find acceptable representation", null); + this.supportedMediaTypes = Collections.unmodifiableList(supportedMediaTypes); + } + + + /** + * Return the list of supported content types in cases when the Accept + * header is parsed but not supported, or an empty list otherwise. + */ + public List getSupportedMediaTypes() { + return this.supportedMediaTypes; + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/ResponseStatusException.java b/spring-web-reactive/src/main/java/org/springframework/web/server/ResponseStatusException.java new file mode 100644 index 0000000000..86cdd15d87 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/ResponseStatusException.java @@ -0,0 +1,68 @@ +/* + * Copyright 2002-2015 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.server; + +import org.springframework.core.NestedRuntimeException; +import org.springframework.http.HttpStatus; +import org.springframework.util.Assert; + +/** + * Base class for exceptions associated with specific HTTP response status codes. + * + * @author Rossen Stoyanchev + */ +public class ResponseStatusException extends NestedRuntimeException { + + private final HttpStatus status; + + private final String reason; + + + /** + * Constructor with a response code and a reason to add to the exception + * message as explanation. + */ + public ResponseStatusException(HttpStatus status, String reason) { + this(status, reason, null); + } + + /** + * Constructor with a nested exception. + */ + public ResponseStatusException(HttpStatus status, String reason, Throwable cause) { + super("Request failure [status: " + status + ", reason: \"" + reason + "\"]", cause); + Assert.notNull(status, "'status' is required"); + Assert.notNull(reason, "'reason' is required"); + this.status = status; + this.reason = reason; + } + + + /** + * The HTTP status that fits the exception. + */ + public HttpStatus getStatus() { + return this.status; + } + + /** + * The reason explaining the exception. + */ + public String getReason() { + return this.reason; + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/ServerErrorException.java b/spring-web-reactive/src/main/java/org/springframework/web/server/ServerErrorException.java new file mode 100644 index 0000000000..9289c3c459 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/ServerErrorException.java @@ -0,0 +1,65 @@ +/* + * Copyright 2002-2016 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.server; + +import java.util.Optional; + +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpStatus; + +/** + * Exception for errors that fit response status 500 (bad request) for use in + * Spring Web applications. The exception provides additional fields (e.g. + * an optional {@link MethodParameter} if related to the error). + * + * @author Rossen Stoyanchev + */ +public class ServerErrorException extends ResponseStatusException { + + private final MethodParameter parameter; + + + /** + * Constructor with an explanation only. + */ + public ServerErrorException(String reason) { + this(reason, null); + } + + /** + * Constructor for a 500 error linked to a specific {@code MethodParameter}. + */ + public ServerErrorException(String reason, MethodParameter parameter) { + this(reason, parameter, null); + } + + /** + * Constructor for a 500 error with a root cause. + */ + public ServerErrorException(String reason, MethodParameter parameter, Throwable cause) { + super(HttpStatus.INTERNAL_SERVER_ERROR, reason, cause); + this.parameter = parameter; + } + + + /** + * Return the {@code MethodParameter} associated with this error, if any. + */ + public Optional getMethodParameter() { + return Optional.ofNullable(this.parameter); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/ServerWebExchange.java b/spring-web-reactive/src/main/java/org/springframework/web/server/ServerWebExchange.java new file mode 100644 index 0000000000..a2b3857c1d --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/ServerWebExchange.java @@ -0,0 +1,68 @@ +/* + * Copyright 2002-2015 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.server; + +import java.util.Map; +import java.util.Optional; + +import reactor.core.publisher.Mono; + +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; + +/** + * Contract for an HTTP request-response interaction. Provides access to the HTTP + * request and response and also exposes additional server-side processing + * related properties and features such as request attributes. + * + * @author Rossen Stoyanchev + */ +public interface ServerWebExchange { + + /** + * Return the current HTTP request. + */ + ServerHttpRequest getRequest(); + + /** + * Return the current HTTP response. + */ + ServerHttpResponse getResponse(); + + /** + * Return a mutable map of request attributes for the current exchange. + */ + Map getAttributes(); + + /** + * Return the request attribute value if present. + * @param name the attribute name + * @param the attribute type + * @return the attribute value + */ + Optional getAttribute(String name); + + /** + * Return the web session for the current request. Always guaranteed to + * return an instance either matching to the session id requested by the + * client, or with a new session id either because the client did not + * specify one or because the underlying session had expired. Use of this + * method does not automatically create a session. See {@link WebSession} + * for more details. + */ + Mono getSession(); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/ServerWebInputException.java b/spring-web-reactive/src/main/java/org/springframework/web/server/ServerWebInputException.java new file mode 100644 index 0000000000..26af9f6a95 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/ServerWebInputException.java @@ -0,0 +1,65 @@ +/* + * Copyright 2002-2016 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.server; + +import java.util.Optional; + +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpStatus; + +/** + * Exception for errors that fit response status 400 (bad request) for use in + * Spring Web applications. The exception provides additional fields (e.g. + * an optional {@link MethodParameter} if related to the error). + * + * @author Rossen Stoyanchev + */ +public class ServerWebInputException extends ResponseStatusException { + + private final MethodParameter parameter; + + + /** + * Constructor with an explanation only. + */ + public ServerWebInputException(String reason) { + this(reason, null); + } + + /** + * Constructor for a 400 error linked to a specific {@code MethodParameter}. + */ + public ServerWebInputException(String reason, MethodParameter parameter) { + this(reason, parameter, null); + } + + /** + * Constructor for a 400 error with a root cause. + */ + public ServerWebInputException(String reason, MethodParameter parameter, Throwable cause) { + super(HttpStatus.BAD_REQUEST, reason, cause); + this.parameter = parameter; + } + + + /** + * Return the {@code MethodParameter} associated with this error, if any. + */ + public Optional getMethodParameter() { + return Optional.ofNullable(this.parameter); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/UnsupportedMediaTypeStatusException.java b/spring-web-reactive/src/main/java/org/springframework/web/server/UnsupportedMediaTypeStatusException.java new file mode 100644 index 0000000000..456913655c --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/UnsupportedMediaTypeStatusException.java @@ -0,0 +1,71 @@ +/* + * Copyright 2002-2016 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.server; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; + +/** + * Exception for errors that fit response status 416 (unsupported media type). + * + * @author Rossen Stoyanchev + */ +public class UnsupportedMediaTypeStatusException extends ResponseStatusException { + + private final MediaType contentType; + + private final List supportedMediaTypes; + + + /** + * Constructor for when the specified Content-Type is invalid. + */ + public UnsupportedMediaTypeStatusException(String reason) { + super(HttpStatus.UNSUPPORTED_MEDIA_TYPE, reason); + this.contentType = null; + this.supportedMediaTypes = Collections.emptyList(); + } + + /** + * Constructor for when the Content-Type can be parsed but is not supported. + */ + public UnsupportedMediaTypeStatusException(MediaType contentType, List supportedMediaTypes) { + super(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "Content type '" + contentType + "' not supported"); + this.contentType = contentType; + this.supportedMediaTypes = Collections.unmodifiableList(supportedMediaTypes); + } + + + /** + * Return the request Content-Type header if it was parsed successfully. + */ + public Optional getContentType() { + return Optional.ofNullable(this.contentType); + } + + /** + * Return the list of supported content types in cases when the Content-Type + * header is parsed but not supported, or an empty list otherwise. + */ + public List getSupportedMediaTypes() { + return this.supportedMediaTypes; + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/WebExceptionHandler.java b/spring-web-reactive/src/main/java/org/springframework/web/server/WebExceptionHandler.java new file mode 100644 index 0000000000..e189498d02 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/WebExceptionHandler.java @@ -0,0 +1,38 @@ +/* + * Copyright 2002-2015 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.server; + +import reactor.core.publisher.Mono; + +/** + * Contract for handling exceptions during web server exchange processing. + * + * @author Rossen Stoyanchev + */ +public interface WebExceptionHandler { + + /** + * Handle the given exception. A completion signal through the return value + * indicates error handling is complete while an error signal indicates the + * exception is still not handled. + * + * @param exchange the current exchange + * @param ex the exception to handle + * @return {@code Mono} to indicate when exception handling is complete + */ + Mono handle(ServerWebExchange exchange, Throwable ex); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/WebFilter.java b/spring-web-reactive/src/main/java/org/springframework/web/server/WebFilter.java new file mode 100644 index 0000000000..6b65e7c6b0 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/WebFilter.java @@ -0,0 +1,40 @@ +/* + * Copyright 2002-2015 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.server; + +import reactor.core.publisher.Mono; + +/** + * Contract for interception-style, chained processing of Web requests that may + * be used to implement cross-cutting, application-agnostic requirements such + * as security, timeouts, and others. + * + * @author Rossen Stoyanchev + */ +public interface WebFilter { + + /** + * Process the Web request and (optionally) delegate to the next + * {@code WebFilter} through the given {@link WebFilterChain}. + * + * @param exchange the current server exchange + * @param chain provides a way to delegate to the next filter + * @return {@code Mono} to indicate when request processing is complete + */ + Mono filter(ServerWebExchange exchange, WebFilterChain chain); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/WebFilterChain.java b/spring-web-reactive/src/main/java/org/springframework/web/server/WebFilterChain.java new file mode 100644 index 0000000000..54863f668e --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/WebFilterChain.java @@ -0,0 +1,35 @@ +/* + * Copyright 2002-2015 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.server; + +import reactor.core.publisher.Mono; + +/** + * Contract to allow a {@link WebFilter} to delegate to the next in the chain. + * + * @author Rossen Stoyanchev + */ +public interface WebFilterChain { + + /** + * Delegate to the next {@code WebFilter} in the chain. + * + * @param exchange the current server exchange + * @return {@code Mono} to indicate when request handling is complete + */ + Mono filter(ServerWebExchange exchange); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/WebHandler.java b/spring-web-reactive/src/main/java/org/springframework/web/server/WebHandler.java new file mode 100644 index 0000000000..45e2da9595 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/WebHandler.java @@ -0,0 +1,44 @@ +/* + * Copyright 2002-2015 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.server; + +import reactor.core.publisher.Mono; + +import org.springframework.web.server.adapter.HttpWebHandlerAdapter; +import org.springframework.web.server.adapter.WebHttpHandlerBuilder; + +/** + * Contract to handle a web request. + * + *

Use {@link HttpWebHandlerAdapter} to adapt a {@code WebHandler} to an + * {@link org.springframework.http.server.reactive.HttpHandler HttpHandler}. + * The {@link WebHttpHandlerBuilder} provides a convenient way to do that while + * also optionally configuring one or more filters and/or exception handlers. + * + * @author Rossen Stoyanchev + */ +public interface WebHandler { + + /** + * Handle the web server exchange. + * + * @param exchange the current server exchange + * @return {@code Mono} to indicate when request handling is complete + */ + Mono handle(ServerWebExchange exchange); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/WebSession.java b/spring-web-reactive/src/main/java/org/springframework/web/server/WebSession.java new file mode 100644 index 0000000000..136daccc15 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/WebSession.java @@ -0,0 +1,118 @@ +/* + * Copyright 2002-2015 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.server; + +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; + +import reactor.core.publisher.Mono; + +/** + * Main contract for using a server-side session that provides access to session + * attributes across HTTP requests. + * + *

The creation of a {@code WebSession} instance does not automatically start + * a session thus causing the session id to be sent to the client (typically via + * a cookie). A session starts implicitly when session attributes are added. + * A session may also be created explicitly via {@link #start()}. + * + * @author Rossen Stoyanchev + */ +public interface WebSession { + + /** + * Return a unique session identifier. + */ + String getId(); + + /** + * Return a map that holds session attributes. + */ + Map getAttributes(); + + /** + * Return the attribute value if present. + * @param name the attribute name + * @param the attribute type + * @return the attribute value + */ + Optional getAttribute(String name); + + /** + * Force the creation of a session causing the session id to be sent when + * {@link #save()} is called. + */ + void start(); + + /** + * Whether a session with the client has been started explicitly via + * {@link #start()} or implicitly by adding session attributes. + * If "false" then the session id is not sent to the client and the + * {@link #save()} method is essentially a no-op. + */ + boolean isStarted(); + + /** + * Save the session persisting attributes (e.g. if stored remotely) and also + * sending the session id to the client if the session is new. + *

Note that a session must be started explicitly via {@link #start()} or + * implicitly by adding attributes or otherwise this method has no effect. + * @return {@code Mono} to indicate completion with success or error + *

Typically this method should be automatically invoked just before the + * response is committed so applications don't have to by default. + */ + Mono save(); + + /** + * Return {@code true} if the session expired after {@link #getMaxIdleTime() + * maxIdleTime} elapsed. + *

Typically expiration checks should be automatically made when a session + * is accessed, a new {@code WebSession} instance created if necessary, at + * the start of request processing so that applications don't have to worry + * about expired session by default. + */ + boolean isExpired(); + + /** + * Return the time when the session was created. + */ + Instant getCreationTime(); + + /** + * Return the last time of session access as a result of user activity such + * as an HTTP request. Together with {@link #getMaxIdleTime() + * maxIdleTimeInSeconds} this helps to determine when a session is + * {@link #isExpired() expired}. + */ + Instant getLastAccessTime(); + + /** + * Configure the max amount of time that may elapse after the + * {@link #getLastAccessTime() lastAccessTime} before a session is considered + * expired. A negative value indicates the session should not expire. + */ + void setMaxIdleTime(Duration maxIdleTime); + + /** + * Return the maximum time after the {@link #getLastAccessTime() + * lastAccessTime} before a session expires. A negative time indicates the + * session doesn't expire. + */ + Duration getMaxIdleTime(); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/adapter/DefaultServerWebExchange.java b/spring-web-reactive/src/main/java/org/springframework/web/server/adapter/DefaultServerWebExchange.java new file mode 100644 index 0000000000..bbd7342fca --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/adapter/DefaultServerWebExchange.java @@ -0,0 +1,84 @@ +/* + * Copyright 2002-2015 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.server.adapter; + +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +import reactor.core.publisher.Mono; + +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.util.Assert; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebSession; +import org.springframework.web.server.session.WebSessionManager; + +/** + * Default implementation of {@link ServerWebExchange}. + * + * @author Rossen Stoyanchev + */ +public class DefaultServerWebExchange implements ServerWebExchange { + + private final ServerHttpRequest request; + + private final ServerHttpResponse response; + + private final Map attributes = new ConcurrentHashMap<>(); + + private final Mono sessionMono; + + + public DefaultServerWebExchange(ServerHttpRequest request, ServerHttpResponse response, + WebSessionManager sessionManager) { + + Assert.notNull(request, "'request' is required."); + Assert.notNull(response, "'response' is required."); + Assert.notNull(response, "'sessionManager' is required."); + this.request = request; + this.response = response; + this.sessionMono = sessionManager.getSession(this).cache(); + } + + + @Override + public ServerHttpRequest getRequest() { + return this.request; + } + + @Override + public ServerHttpResponse getResponse() { + return this.response; + } + + @Override + public Map getAttributes() { + return this.attributes; + } + + @Override @SuppressWarnings("unchecked") + public Optional getAttribute(String name) { + return Optional.ofNullable((T) this.attributes.get(name)); + } + + @Override + public Mono getSession() { + return this.sessionMono; + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/adapter/HttpWebHandlerAdapter.java b/spring-web-reactive/src/main/java/org/springframework/web/server/adapter/HttpWebHandlerAdapter.java new file mode 100644 index 0000000000..8f8ccf2f1f --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/adapter/HttpWebHandlerAdapter.java @@ -0,0 +1,92 @@ +/* + * Copyright 2002-2015 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.server.adapter; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import reactor.core.publisher.Mono; + +import org.springframework.http.HttpStatus; +import org.springframework.http.server.reactive.HttpHandler; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.util.Assert; +import org.springframework.web.server.WebHandler; +import org.springframework.web.server.handler.WebHandlerDecorator; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.session.DefaultWebSessionManager; +import org.springframework.web.server.session.WebSessionManager; + +/** + * Default adapter of {@link WebHandler} to the {@link HttpHandler} contract. + * + *

By default creates and configures a {@link DefaultServerWebExchange} and + * then invokes the target {@code WebHandler}. + * + * @author Rossen Stoyanchev + */ +public class HttpWebHandlerAdapter extends WebHandlerDecorator implements HttpHandler { + + private static Log logger = LogFactory.getLog(HttpWebHandlerAdapter.class); + + + private WebSessionManager sessionManager = new DefaultWebSessionManager(); + + + public HttpWebHandlerAdapter(WebHandler delegate) { + super(delegate); + } + + + /** + * Configure a custom {@link WebSessionManager} to use for managing web + * sessions. The provided instance is set on each created + * {@link DefaultServerWebExchange}. + *

By default this is set to {@link DefaultWebSessionManager}. + * @param sessionManager the session manager to use + */ + public void setSessionManager(WebSessionManager sessionManager) { + Assert.notNull(sessionManager, "'sessionManager' must not be null."); + this.sessionManager = sessionManager; + } + + /** + * Return the configured {@link WebSessionManager}. + */ + public WebSessionManager getSessionManager() { + return this.sessionManager; + } + + + @Override + public Mono handle(ServerHttpRequest request, ServerHttpResponse response) { + ServerWebExchange exchange = createExchange(request, response); + return getDelegate().handle(exchange) + .otherwise(ex -> { + if (logger.isDebugEnabled()) { + logger.debug("Could not complete request", ex); + } + response.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR); + return Mono.empty(); + }) + .then(response::setComplete); + } + + protected ServerWebExchange createExchange(ServerHttpRequest request, ServerHttpResponse response) { + return new DefaultServerWebExchange(request, response, this.sessionManager); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/adapter/WebHttpHandlerBuilder.java b/spring-web-reactive/src/main/java/org/springframework/web/server/adapter/WebHttpHandlerBuilder.java new file mode 100644 index 0000000000..8893bf0460 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/adapter/WebHttpHandlerBuilder.java @@ -0,0 +1,136 @@ +/* + * Copyright 2002-2016 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.server.adapter; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.springframework.http.server.reactive.HttpHandler; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebExceptionHandler; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebHandler; +import org.springframework.web.server.handler.ExceptionHandlingWebHandler; +import org.springframework.web.server.handler.FilteringWebHandler; +import org.springframework.web.server.session.WebSessionManager; + +/** + * Builder for an {@link HttpHandler} that adapts to a target {@link WebHandler} + * along with a chain of {@link WebFilter}s and a set of + * {@link WebExceptionHandler}s. + * + *

Example usage: + *

+ * WebFilter filter = ... ;
+ * WebHandler webHandler = ... ;
+ * WebExceptionHandler exceptionHandler = ...;
+ *
+ * HttpHandler httpHandler = WebHttpHandlerBuilder.webHandler(webHandler)
+ *         .filters(filter)
+ *         .exceptionHandlers(exceptionHandler)
+ *         .build();
+ * 
+ * + * @author Rossen Stoyanchev + */ +public class WebHttpHandlerBuilder { + + private final WebHandler targetHandler; + + private final List filters = new ArrayList<>(); + + private final List exceptionHandlers = new ArrayList<>(); + + private WebSessionManager sessionManager; + + + /** + * Private constructor. + * See factory method {@link #webHandler(WebHandler)}. + */ + private WebHttpHandlerBuilder(WebHandler targetHandler) { + Assert.notNull(targetHandler, "'targetHandler' must not be null"); + this.targetHandler = targetHandler; + } + + + /** + * Factory method to create a new builder instance. + * @param webHandler the target handler for the request + */ + public static WebHttpHandlerBuilder webHandler(WebHandler webHandler) { + return new WebHttpHandlerBuilder(webHandler); + } + + + /** + * Add the given filters to use for processing requests. + * @param filters the filters to add + */ + public WebHttpHandlerBuilder filters(WebFilter... filters) { + if (!ObjectUtils.isEmpty(filters)) { + this.filters.addAll(Arrays.asList(filters)); + } + return this; + } + + /** + * Add the given exception handler to apply at the end of request processing. + * @param exceptionHandlers the exception handlers + */ + public WebHttpHandlerBuilder exceptionHandlers(WebExceptionHandler... exceptionHandlers) { + if (!ObjectUtils.isEmpty(exceptionHandlers)) { + this.exceptionHandlers.addAll(Arrays.asList(exceptionHandlers)); + } + return this; + } + + /** + * Configure the {@link WebSessionManager} to set on the + * {@link ServerWebExchange WebServerExchange} + * created for each HTTP request. + * @param sessionManager the session manager + * @see HttpWebHandlerAdapter#setSessionManager(WebSessionManager) + */ + public WebHttpHandlerBuilder sessionManager(WebSessionManager sessionManager) { + this.sessionManager = sessionManager; + return this; + } + + /** + * Build the {@link HttpHandler}. + */ + public HttpHandler build() { + WebHandler webHandler = this.targetHandler; + if (!this.filters.isEmpty()) { + WebFilter[] array = new WebFilter[this.filters.size()]; + webHandler = new FilteringWebHandler(webHandler, this.filters.toArray(array)); + } + if (!this.exceptionHandlers.isEmpty()) { + WebExceptionHandler[] array = new WebExceptionHandler[this.exceptionHandlers.size()]; + webHandler = new ExceptionHandlingWebHandler(webHandler, this.exceptionHandlers.toArray(array)); + } + HttpWebHandlerAdapter httpHandler = new HttpWebHandlerAdapter(webHandler); + if (this.sessionManager != null) { + httpHandler.setSessionManager(this.sessionManager); + } + return httpHandler; + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/adapter/package-info.java b/spring-web-reactive/src/main/java/org/springframework/web/server/adapter/package-info.java new file mode 100644 index 0000000000..f3cb8231ed --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/adapter/package-info.java @@ -0,0 +1,6 @@ +/** + * Implementation support to adapt + * {@link org.springframework.web.server Spring web server} to the underlying + * {@link org.springframework.http.server.reactive HTTP server} layer. + */ +package org.springframework.web.server.adapter; diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/handler/ExceptionHandlingWebHandler.java b/spring-web-reactive/src/main/java/org/springframework/web/server/handler/ExceptionHandlingWebHandler.java new file mode 100644 index 0000000000..a21433f33d --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/handler/ExceptionHandlingWebHandler.java @@ -0,0 +1,86 @@ +/* + * Copyright 2002-2015 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.server.handler; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import reactor.core.publisher.Mono; + +import org.springframework.http.HttpStatus; +import org.springframework.web.server.WebExceptionHandler; +import org.springframework.web.server.WebHandler; +import org.springframework.web.server.ServerWebExchange; + +/** + * WebHandler that can invoke a target {@link WebHandler} and then apply + * exception handling with one or more {@link WebExceptionHandler} instances. + * + * @author Rossen Stoyanchev + */ +public class ExceptionHandlingWebHandler extends WebHandlerDecorator { + + private static Log logger = LogFactory.getLog(ExceptionHandlingWebHandler.class); + + + private final List exceptionHandlers; + + + public ExceptionHandlingWebHandler(WebHandler delegate, WebExceptionHandler... exceptionHandlers) { + super(delegate); + this.exceptionHandlers = initList(exceptionHandlers); + } + + private static List initList(WebExceptionHandler[] list) { + return (list != null ? Collections.unmodifiableList(Arrays.asList(list)): Collections.emptyList()); + } + + + /** + * Return a read-only list of the configured exception handlers. + */ + public List getExceptionHandlers() { + return this.exceptionHandlers; + } + + + @Override + public Mono handle(ServerWebExchange exchange) { + Mono mono; + try { + mono = getDelegate().handle(exchange); + } + catch (Throwable ex) { + mono = Mono.error(ex); + } + for (WebExceptionHandler exceptionHandler : this.exceptionHandlers) { + mono = mono.otherwise(ex -> exceptionHandler.handle(exchange, ex)); + } + return mono.otherwise(ex -> handleUnresolvedException(exchange, ex)); + } + + private Mono handleUnresolvedException(ServerWebExchange exchange, Throwable ex) { + if (logger.isDebugEnabled()) { + logger.debug("Could not complete request", ex); + } + exchange.getResponse().setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR); + return Mono.empty(); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/handler/FilteringWebHandler.java b/spring-web-reactive/src/main/java/org/springframework/web/server/handler/FilteringWebHandler.java new file mode 100644 index 0000000000..de12581575 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/handler/FilteringWebHandler.java @@ -0,0 +1,80 @@ +/* + * Copyright 2002-2016 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.server.handler; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import reactor.core.publisher.Mono; + +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import org.springframework.web.server.WebHandler; +import org.springframework.web.server.ServerWebExchange; + +/** + * WebHandler that delegates to a chain of {@link WebFilter} instances and then + * to the target {@link WebHandler}. + * + * @author Rossen Stoyanchev + */ +public class FilteringWebHandler extends WebHandlerDecorator { + + private final List filters; + + + public FilteringWebHandler(WebHandler targetHandler, WebFilter... filters) { + super(targetHandler); + this.filters = initList(filters); + } + + private static List initList(WebFilter[] list) { + return (list != null ? Collections.unmodifiableList(Arrays.asList(list)): Collections.emptyList()); + } + + + /** + * Return a read-only list of the configured filters. + */ + public List getFilters() { + return this.filters; + } + + @Override + public Mono handle(ServerWebExchange exchange) { + return new DefaultWebFilterChain().filter(exchange); + } + + + private class DefaultWebFilterChain implements WebFilterChain { + + private int index; + + + @Override + public Mono filter(ServerWebExchange exchange) { + if (this.index < filters.size()) { + WebFilter filter = filters.get(this.index++); + return filter.filter(exchange, this); + } + else { + return getDelegate().handle(exchange); + } + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/handler/WebHandlerDecorator.java b/spring-web-reactive/src/main/java/org/springframework/web/server/handler/WebHandlerDecorator.java new file mode 100644 index 0000000000..5fa614af59 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/handler/WebHandlerDecorator.java @@ -0,0 +1,55 @@ +/* + * Copyright 2002-2015 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.server.handler; + +import reactor.core.publisher.Mono; + +import org.springframework.util.Assert; +import org.springframework.web.server.WebHandler; +import org.springframework.web.server.ServerWebExchange; + +/** + * {@link WebHandler} that decorates and delegates to another. + * + * @author Rossen Stoyanchev + */ +public class WebHandlerDecorator implements WebHandler { + + private final WebHandler delegate; + + + public WebHandlerDecorator(WebHandler delegate) { + Assert.notNull(delegate, "'delegate' must not be null"); + this.delegate = delegate; + } + + + public WebHandler getDelegate() { + return this.delegate; + } + + + @Override + public Mono handle(ServerWebExchange exchange) { + return this.delegate.handle(exchange); + } + + @Override + public String toString() { + return getClass().getSimpleName() + " [delegate=" + this.delegate + "]"; + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/handler/package-info.java b/spring-web-reactive/src/main/java/org/springframework/web/server/handler/package-info.java new file mode 100644 index 0000000000..95c56f6941 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/handler/package-info.java @@ -0,0 +1,4 @@ +/** + * Provides WebHandler implementations. + */ +package org.springframework.web.server.handler; diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/package-info.java b/spring-web-reactive/src/main/java/org/springframework/web/server/package-info.java new file mode 100644 index 0000000000..fcfe7bdaa9 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/package-info.java @@ -0,0 +1,4 @@ +/** + * Foundational Spring web server support. + */ +package org.springframework.web.server; diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/session/ConfigurableWebSession.java b/spring-web-reactive/src/main/java/org/springframework/web/server/session/ConfigurableWebSession.java new file mode 100644 index 0000000000..6a91b3f219 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/session/ConfigurableWebSession.java @@ -0,0 +1,45 @@ +/* + * Copyright 2002-2016 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.server.session; + +import java.time.Instant; +import java.util.function.Supplier; + +import reactor.core.publisher.Mono; + +import org.springframework.web.server.WebSession; + +/** + * Extend {@link WebSession} with management operations meant for internal use + * for example by implementations of {@link WebSessionManager}. + * + * @author Rossen Stoyanchev + */ +public interface ConfigurableWebSession extends WebSession { + + /** + * Update the last access time for user-related session activity. + * @param time the time of access + */ + void setLastAccessTime(Instant time); + + /** + * Set the operation to invoke when {@link WebSession#save()} is invoked. + * @param saveOperation the save operation + */ + void setSaveOperation(Supplier> saveOperation); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/session/CookieWebSessionIdResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/server/session/CookieWebSessionIdResolver.java new file mode 100644 index 0000000000..a992f0057d --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/session/CookieWebSessionIdResolver.java @@ -0,0 +1,95 @@ +/* + * Copyright 2002-2016 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.server.session; + +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.http.HttpCookie; +import org.springframework.http.ResponseCookie; +import org.springframework.util.Assert; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ServerWebExchange; + +/** + * Cookie-based {@link WebSessionIdResolver}. + * + * @author Rossen Stoyanchev + */ +public class CookieWebSessionIdResolver implements WebSessionIdResolver { + + private String cookieName = "SESSION"; + + private Duration cookieMaxAge = Duration.ofSeconds(-1); + + + /** + * Set the name of the cookie to use for the session id. + *

By default set to "SESSION". + * @param cookieName the cookie name + */ + public void setCookieName(String cookieName) { + Assert.hasText(cookieName, "'cookieName' must not be empty."); + this.cookieName = cookieName; + } + + /** + * Return the configured cookie name. + */ + public String getCookieName() { + return this.cookieName; + } + + /** + * Set the value for the "Max-Age" attribute of the cookie that holds the + * session id. For the range of values see {@link ResponseCookie#getMaxAge()}. + *

By default set to -1. + * @param maxAge the maxAge duration value + */ + public void setCookieMaxAge(Duration maxAge) { + this.cookieMaxAge = maxAge; + } + + /** + * Return the configured "Max-Age" attribute value for the session cookie. + */ + public Duration getCookieMaxAge() { + return this.cookieMaxAge; + } + + + @Override + public List resolveSessionIds(ServerWebExchange exchange) { + MultiValueMap cookieMap = exchange.getRequest().getCookies(); + List cookies = cookieMap.get(getCookieName()); + if (cookies == null) { + return Collections.emptyList(); + } + return cookies.stream().map(HttpCookie::getValue).collect(Collectors.toList()); + } + + @Override + public void setSessionId(ServerWebExchange exchange, String id) { + Duration maxAge = (StringUtils.hasText(id) ? getCookieMaxAge() : Duration.ofSeconds(0)); + ResponseCookie cookie = ResponseCookie.from(getCookieName(), id).maxAge(maxAge).build(); + MultiValueMap cookieMap = exchange.getResponse().getCookies(); + cookieMap.set(getCookieName(), cookie); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/session/DefaultWebSession.java b/spring-web-reactive/src/main/java/org/springframework/web/server/session/DefaultWebSession.java new file mode 100644 index 0000000000..146c4e0a8e --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/session/DefaultWebSession.java @@ -0,0 +1,175 @@ +/* + * Copyright 2002-2016 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.server.session; + +import java.io.Serializable; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; + +import reactor.core.publisher.Mono; + +import org.springframework.util.Assert; + +/** + * @author Rossen Stoyanchev + */ +public class DefaultWebSession implements ConfigurableWebSession, Serializable { + + private final String id; + + private final Map attributes; + + private final Clock clock; + + private final Instant creationTime; + + private volatile Instant lastAccessTime; + + private volatile Duration maxIdleTime; + + private AtomicReference state = new AtomicReference<>(); + + private volatile transient Supplier> saveOperation; + + + /** + * Constructor to create a new session. + * @param id the session id + * @param clock for access to current time + */ + public DefaultWebSession(String id, Clock clock) { + Assert.notNull(id, "'id' is required."); + Assert.notNull(clock, "'clock' is required."); + this.id = id; + this.clock = clock; + this.attributes = new ConcurrentHashMap<>(); + this.creationTime = Instant.now(clock); + this.lastAccessTime = this.creationTime; + this.maxIdleTime = Duration.ofMinutes(30); + this.state.set(State.NEW); + } + + /** + * Constructor to load existing session. + * @param id the session id + * @param attributes the attributes of the session + * @param clock for access to current time + * @param creationTime the creation time + * @param lastAccessTime the last access time + * @param maxIdleTime the configured maximum session idle time + */ + public DefaultWebSession(String id, Map attributes, Clock clock, + Instant creationTime, Instant lastAccessTime, Duration maxIdleTime) { + + Assert.notNull(id, "'id' is required."); + Assert.notNull(clock, "'clock' is required."); + this.id = id; + this.attributes = new ConcurrentHashMap<>(attributes); + this.clock = clock; + this.creationTime = creationTime; + this.lastAccessTime = lastAccessTime; + this.maxIdleTime = maxIdleTime; + this.state.set(State.STARTED); + } + + + @Override + public String getId() { + return this.id; + } + + @Override + public Map getAttributes() { + return this.attributes; + } + + @Override @SuppressWarnings("unchecked") + public Optional getAttribute(String name) { + return Optional.ofNullable((T) this.attributes.get(name)); + } + + @Override + public Instant getCreationTime() { + return this.creationTime; + } + + @Override + public void setLastAccessTime(Instant lastAccessTime) { + this.lastAccessTime = lastAccessTime; + } + + @Override + public Instant getLastAccessTime() { + return this.lastAccessTime; + } + + /** + *

By default this is set to 30 minutes. + * @param maxIdleTime the max idle time + */ + @Override + public void setMaxIdleTime(Duration maxIdleTime) { + this.maxIdleTime = maxIdleTime; + } + + @Override + public Duration getMaxIdleTime() { + return this.maxIdleTime; + } + + @Override + public void setSaveOperation(Supplier> saveOperation) { + Assert.notNull(saveOperation, "'saveOperation' is required."); + this.saveOperation = saveOperation; + } + + protected Supplier> getSaveOperation() { + return this.saveOperation; + } + + + @Override + public void start() { + this.state.compareAndSet(State.NEW, State.STARTED); + } + + @Override + public boolean isStarted() { + State value = this.state.get(); + return (State.STARTED.equals(value) || (State.NEW.equals(value) && !getAttributes().isEmpty())); + } + + @Override + public Mono save() { + return this.saveOperation.get(); + } + + @Override + public boolean isExpired() { + return (isStarted() && !this.maxIdleTime.isNegative() && + Instant.now(this.clock).minus(this.maxIdleTime).isAfter(this.lastAccessTime)); + } + + + private enum State { NEW, STARTED } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/session/DefaultWebSessionManager.java b/spring-web-reactive/src/main/java/org/springframework/web/server/session/DefaultWebSessionManager.java new file mode 100644 index 0000000000..6e51319ac2 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/session/DefaultWebSessionManager.java @@ -0,0 +1,158 @@ +/* + * Copyright 2002-2016 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.server.session; + +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.util.Assert; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebSession; + + +/** + * Default implementation of {@link WebSessionManager} with a cookie-based web + * session id resolution strategy and simple in-memory session persistence. + * + * @author Rossen Stoyanchev + */ +public class DefaultWebSessionManager implements WebSessionManager { + + private WebSessionIdResolver sessionIdResolver = new CookieWebSessionIdResolver(); + + private WebSessionStore sessionStore = new InMemoryWebSessionStore(); + + private Clock clock = Clock.systemDefaultZone(); + + + /** + * Configure the session id resolution strategy to use. + *

By default {@link CookieWebSessionIdResolver} is used. + * @param sessionIdResolver the resolver + */ + public void setSessionIdResolver(WebSessionIdResolver sessionIdResolver) { + Assert.notNull(sessionIdResolver, "'sessionIdResolver' is required."); + this.sessionIdResolver = sessionIdResolver; + } + + /** + * Return the configured {@link WebSessionIdResolver}. + */ + public WebSessionIdResolver getSessionIdResolver() { + return this.sessionIdResolver; + } + + /** + * Configure the session persistence strategy to use. + *

By default {@link InMemoryWebSessionStore} is used. + * @param sessionStore the persistence strategy + */ + public void setSessionStore(WebSessionStore sessionStore) { + Assert.notNull(sessionStore, "'sessionStore' is required."); + this.sessionStore = sessionStore; + } + + /** + * Return the configured {@link WebSessionStore}. + */ + public WebSessionStore getSessionStore() { + return this.sessionStore; + } + + /** + * Configure the {@link Clock} for access to current time. During tests you + * may use {code Clock.offset(clock, Duration.ofMinutes(-31))} to set the + * clock back for example to test changes after sessions expire. + *

By default {@link Clock#systemDefaultZone()} is used. + * @param clock the clock to use + */ + public void setClock(Clock clock) { + Assert.notNull(clock, "'clock' is required."); + this.clock = clock; + } + + /** + * Return the configured clock for access to current time. + */ + public Clock getClock() { + return this.clock; + } + + + @Override + public Mono getSession(ServerWebExchange exchange) { + return Mono.defer(() -> + Flux.fromIterable(getSessionIdResolver().resolveSessionIds(exchange)) + .concatMap(this.sessionStore::retrieveSession) + .next() + .then(session -> validateSession(exchange, session)) + .otherwiseIfEmpty(createSession(exchange)) + .map(session -> extendSession(exchange, session))); + } + + protected Mono validateSession(ServerWebExchange exchange, WebSession session) { + if (session.isExpired()) { + this.sessionIdResolver.setSessionId(exchange, ""); + return this.sessionStore.removeSession(session.getId()).cast(WebSession.class); + } + else { + return Mono.just(session); + } + } + + protected Mono createSession(ServerWebExchange exchange) { + String sessionId = UUID.randomUUID().toString(); + WebSession session = new DefaultWebSession(sessionId, getClock()); + return Mono.just(session); + } + + protected WebSession extendSession(ServerWebExchange exchange, WebSession session) { + if (session instanceof ConfigurableWebSession) { + ConfigurableWebSession managed = (ConfigurableWebSession) session; + managed.setSaveOperation(() -> saveSession(exchange, session)); + managed.setLastAccessTime(Instant.now(getClock())); + } + exchange.getResponse().beforeCommit(session::save); + return session; + } + + protected Mono saveSession(ServerWebExchange exchange, WebSession session) { + + Assert.isTrue(!session.isExpired(), "Sessions are checked for expiration and have their " + + "access time updated when first accessed during request processing. " + + "However this session is expired meaning that maxIdleTime elapsed " + + "since then and before the call to session.save()."); + + if (!session.isStarted()) { + return Mono.empty(); + } + + // Force explicit start + session.start(); + + List requestedIds = getSessionIdResolver().resolveSessionIds(exchange); + if (requestedIds.isEmpty() || !session.getId().equals(requestedIds.get(0))) { + this.sessionIdResolver.setSessionId(exchange, session.getId()); + } + return this.sessionStore.storeSession(session); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/session/InMemoryWebSessionStore.java b/spring-web-reactive/src/main/java/org/springframework/web/server/session/InMemoryWebSessionStore.java new file mode 100644 index 0000000000..7e1bb388a3 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/session/InMemoryWebSessionStore.java @@ -0,0 +1,52 @@ +/* + * Copyright 2002-2016 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.server.session; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import reactor.core.publisher.Mono; + +import org.springframework.web.server.WebSession; + +/** + * Simple Map-based storage for {@link WebSession} instances. + * + * @author Rossen Stoyanchev + */ +public class InMemoryWebSessionStore implements WebSessionStore { + + private final Map sessions = new ConcurrentHashMap<>(); + + + @Override + public Mono storeSession(WebSession session) { + this.sessions.put(session.getId(), session); + return Mono.empty(); + } + + @Override + public Mono retrieveSession(String id) { + return (this.sessions.containsKey(id) ? Mono.just(this.sessions.get(id)) : Mono.empty()); + } + + @Override + public Mono removeSession(String id) { + this.sessions.remove(id); + return Mono.empty(); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/session/WebSessionIdResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/server/session/WebSessionIdResolver.java new file mode 100644 index 0000000000..77884e7370 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/session/WebSessionIdResolver.java @@ -0,0 +1,48 @@ +/* + * Copyright 2002-2016 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.server.session; + +import java.util.List; + +import org.springframework.web.server.ServerWebExchange; + + +/** + * Contract for session id resolution strategies. Allows for session id + * resolution through the request and for sending the session id to the + * client through the response. + * + * @author Rossen Stoyanchev + * @see CookieWebSessionIdResolver + */ +public interface WebSessionIdResolver { + + /** + * Resolve the session id's associated with the request. + * @param exchange the current exchange + * @return the session id's or an empty list + */ + List resolveSessionIds(ServerWebExchange exchange); + + /** + * Send the given session id to the client or if the session id is "null" + * instruct the client to end the current session. + * @param exchange the current exchange + * @param sessionId the session id + */ + void setSessionId(ServerWebExchange exchange, String sessionId); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/session/WebSessionManager.java b/spring-web-reactive/src/main/java/org/springframework/web/server/session/WebSessionManager.java new file mode 100644 index 0000000000..f0ad012150 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/session/WebSessionManager.java @@ -0,0 +1,48 @@ +/* + * Copyright 2002-2016 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.server.session; + +import reactor.core.publisher.Mono; + +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebSession; + +/** + * Main contract abstracting support for access to {@link WebSession} instances + * associated with HTTP requests as well as the subsequent management such as + * persistence and others. + * + *

The {@link DefaultWebSessionManager} implementation in turn delegates to + * {@link WebSessionIdResolver} and {@link WebSessionStore} which abstract + * underlying concerns related to the management of web sessions. + * + * @author Rossen Stoyanchev + * @see WebSessionIdResolver + * @see WebSessionStore + */ +public interface WebSessionManager { + + /** + * Return the {@link WebSession} for the given exchange. Always guaranteed + * to return an instance either matching to the session id requested by the + * client, or with a new session id either because the client did not + * specify one or because the underlying session had expired. + * @param exchange the current exchange + * @return {@code Mono} for async access to the session + */ + Mono getSession(ServerWebExchange exchange); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/session/WebSessionStore.java b/spring-web-reactive/src/main/java/org/springframework/web/server/session/WebSessionStore.java new file mode 100644 index 0000000000..998e298e69 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/session/WebSessionStore.java @@ -0,0 +1,51 @@ +/* + * Copyright 2002-2016 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.server.session; + +import reactor.core.publisher.Mono; + +import org.springframework.web.server.WebSession; + +/** + * Strategy for {@link WebSession} persistence. + * + * @author Rossen Stoyanchev + * @since 4.3 + */ +public interface WebSessionStore { + + /** + * Store the given session. + * @param session the session to store + * @return {@code Mono} for completion notification + */ + Mono storeSession(WebSession session); + + /** + * Load the session for the given session id. + * @param sessionId the session to load + * @return {@code Mono} for async access to the loaded session + */ + Mono retrieveSession(String sessionId); + + /** + * Remove the session with the given id. + * @param sessionId the session to remove + * @return {@code Mono} for completion notification + */ + Mono removeSession(String sessionId); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/session/package-info.java b/spring-web-reactive/src/main/java/org/springframework/web/server/session/package-info.java new file mode 100644 index 0000000000..1a62756b01 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/session/package-info.java @@ -0,0 +1,4 @@ +/** + * Web session support. + */ +package org.springframework.web.server.session; diff --git a/spring-web-reactive/src/main/java/org/springframework/web/util/HttpRequestPathHelper.java b/spring-web-reactive/src/main/java/org/springframework/web/util/HttpRequestPathHelper.java new file mode 100644 index 0000000000..259033df7b --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/util/HttpRequestPathHelper.java @@ -0,0 +1,115 @@ +/* + * Copyright 2002-2016 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.util; + +import java.io.UnsupportedEncodingException; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.server.ServerWebExchange; + +/** + * A helper class to obtain the lookup path for path matching purposes. + * + * @author Rossen Stoyanchev + */ +public class HttpRequestPathHelper { + + private boolean urlDecode = true; + + + // TODO: sanitize path, default/request encoding?, remove path params? + + /** + * Set if the request path should be URL-decoded. + *

Default is "true". + * @see UriUtils#decode(String, String) + */ + public void setUrlDecode(boolean urlDecode) { + this.urlDecode = urlDecode; + } + + /** + * Whether the request path should be URL decoded. + */ + public boolean shouldUrlDecode() { + return this.urlDecode; + } + + + public String getLookupPathForRequest(ServerWebExchange exchange) { + String path = exchange.getRequest().getURI().getRawPath(); + return (this.shouldUrlDecode() ? decode(exchange, path) : path); + } + + private String decode(ServerWebExchange exchange, String path) { + // TODO: look up request encoding? + try { + return UriUtils.decode(path, "UTF-8"); + } + catch (UnsupportedEncodingException ex) { + // Should not happen + throw new IllegalStateException("Could not decode request string [" + path + "]"); + } + } + + /** + * Decode the given URI path variables unless {@link #setUrlDecode(boolean)} + * is set to {@code true} in which case it is assumed the URL path from + * which the variables were extracted is already decoded through a call to + * {@link #getLookupPathForRequest(ServerWebExchange)}. + * @param exchange current exchange + * @param vars URI variables extracted from the URL path + * @return the same Map or a new Map instance + */ + public Map decodePathVariables(ServerWebExchange exchange, Map vars) { + if (this.urlDecode) { + return vars; + } + Map decodedVars = new LinkedHashMap<>(vars.size()); + for (Map.Entry entry : vars.entrySet()) { + decodedVars.put(entry.getKey(), decode(exchange, entry.getValue())); + } + return decodedVars; + } + + /** + * Decode the given matrix variables unless {@link #setUrlDecode(boolean)} + * is set to {@code true} in which case it is assumed the URL path from + * which the variables were extracted is already decoded through a call to + * {@link #getLookupPathForRequest(ServerWebExchange)}. + * @param exchange current exchange + * @param vars URI variables extracted from the URL path + * @return the same Map or a new Map instance + */ + public MultiValueMap decodeMatrixVariables(ServerWebExchange exchange, + MultiValueMap vars) { + + if (this.urlDecode) { + return vars; + } + MultiValueMap decodedVars = new LinkedMultiValueMap<>(vars.size()); + for (String key : vars.keySet()) { + for (String value : vars.get(key)) { + decodedVars.add(key, decode(exchange, value)); + } + } + return decodedVars; + } + +} \ No newline at end of file diff --git a/spring-web-reactive/src/test/java/org/springframework/core/codec/ByteBufferDecoderTests.java b/spring-web-reactive/src/test/java/org/springframework/core/codec/ByteBufferDecoderTests.java new file mode 100644 index 0000000000..8162200f34 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/core/codec/ByteBufferDecoderTests.java @@ -0,0 +1,65 @@ +/* + * Copyright 2002-2016 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.core.codec; + +import java.nio.ByteBuffer; + +import org.junit.Test; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.test.TestSubscriber; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.AbstractDataBufferAllocatingTestCase; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.MediaType; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * @author Sebastien Deleuze + */ +public class ByteBufferDecoderTests extends AbstractDataBufferAllocatingTestCase { + + private final ByteBufferDecoder decoder = new ByteBufferDecoder(); + + @Test + public void canDecode() { + assertTrue(this.decoder.canDecode(ResolvableType.forClass(ByteBuffer.class), + MediaType.TEXT_PLAIN)); + assertFalse(this.decoder + .canDecode(ResolvableType.forClass(Integer.class), MediaType.TEXT_PLAIN)); + assertTrue(this.decoder.canDecode(ResolvableType.forClass(ByteBuffer.class), + MediaType.APPLICATION_JSON)); + } + + @Test + public void decode() { + DataBuffer fooBuffer = stringBuffer("foo"); + DataBuffer barBuffer = stringBuffer("bar"); + Flux source = Flux.just(fooBuffer, barBuffer); + Flux output = this.decoder.decode(source, + ResolvableType.forClassWithGenerics(Publisher.class, ByteBuffer.class), + null); + TestSubscriber + .subscribe(output) + .assertNoError() + .assertComplete() + .assertValues(ByteBuffer.wrap("foo".getBytes()), ByteBuffer.wrap("bar".getBytes())); + } +} diff --git a/spring-web-reactive/src/test/java/org/springframework/core/codec/ByteBufferEncoderTests.java b/spring-web-reactive/src/test/java/org/springframework/core/codec/ByteBufferEncoderTests.java new file mode 100644 index 0000000000..157724d3b1 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/core/codec/ByteBufferEncoderTests.java @@ -0,0 +1,80 @@ +/* + * Copyright 2002-2016 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.core.codec; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; + +import org.junit.Before; +import org.junit.Test; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.test.TestSubscriber; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.AbstractDataBufferAllocatingTestCase; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.MediaType; + +import static org.junit.Assert.*; + +/** + * @author Sebastien Deleuze + */ +public class ByteBufferEncoderTests extends AbstractDataBufferAllocatingTestCase { + + private ByteBufferEncoder encoder; + + @Before + public void createEncoder() { + this.encoder = new ByteBufferEncoder(); + } + + @Test + public void canEncode() { + assertTrue(this.encoder.canEncode(ResolvableType.forClass(ByteBuffer.class), + MediaType.TEXT_PLAIN)); + assertFalse(this.encoder + .canEncode(ResolvableType.forClass(Integer.class), MediaType.TEXT_PLAIN)); + assertTrue(this.encoder.canEncode(ResolvableType.forClass(ByteBuffer.class), + MediaType.APPLICATION_JSON)); + } + + @Test + public void encode() { + byte[] fooBytes = "foo".getBytes(StandardCharsets.UTF_8); + byte[] barBytes = "bar".getBytes(StandardCharsets.UTF_8); + Flux source = + Flux.just(ByteBuffer.wrap(fooBytes), ByteBuffer.wrap(barBytes)); + + Flux output = this.encoder.encode(source, this.bufferFactory, + ResolvableType.forClassWithGenerics(Publisher.class, ByteBuffer.class), + null); + TestSubscriber + .subscribe(output) + .assertValuesWith(b -> { + byte[] buf = new byte[3]; + b.read(buf); + assertArrayEquals(fooBytes, buf); + }, b -> { + byte[] buf = new byte[3]; + b.read(buf); + assertArrayEquals(barBytes, buf); + }); + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/core/codec/ResourceDecoderTests.java b/spring-web-reactive/src/test/java/org/springframework/core/codec/ResourceDecoderTests.java new file mode 100644 index 0000000000..15db097214 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/core/codec/ResourceDecoderTests.java @@ -0,0 +1,84 @@ +/* + * Copyright 2002-2016 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.core.codec; + +import java.io.IOException; + +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.test.TestSubscriber; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.buffer.AbstractDataBufferAllocatingTestCase; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.MediaType; +import org.springframework.util.StreamUtils; + +import static org.junit.Assert.*; + +/** + * @author Arjen Poutsma + */ +public class ResourceDecoderTests extends AbstractDataBufferAllocatingTestCase { + + private final ResourceDecoder decoder = new ResourceDecoder(); + + @Test + public void canDecode() throws Exception { + assertTrue( + this.decoder.canDecode(ResolvableType.forClass(InputStreamResource.class), + MediaType.TEXT_PLAIN)); + assertTrue( + this.decoder.canDecode(ResolvableType.forClass(ByteArrayResource.class), + MediaType.TEXT_PLAIN)); + assertTrue(this.decoder.canDecode(ResolvableType.forClass(Resource.class), + MediaType.TEXT_PLAIN)); + assertTrue( + this.decoder.canDecode(ResolvableType.forClass(InputStreamResource.class), + MediaType.APPLICATION_JSON)); + } + + @Test + public void decode() throws Exception { + DataBuffer fooBuffer = stringBuffer("foo"); + DataBuffer barBuffer = stringBuffer("bar"); + Flux source = Flux.just(fooBuffer, barBuffer); + + Flux result = this.decoder + .decode(source, ResolvableType.forClass(Resource.class), null); + + TestSubscriber + .subscribe(result) + .assertNoError() + .assertComplete() + .assertValuesWith(resource -> { + try { + byte[] bytes = + StreamUtils.copyToByteArray(resource.getInputStream()); + assertEquals("foobar", new String(bytes)); + } + catch (IOException e) { + fail(e.getMessage()); + } + }); + + } + +} \ No newline at end of file diff --git a/spring-web-reactive/src/test/java/org/springframework/core/codec/ResourceEncoderTests.java b/spring-web-reactive/src/test/java/org/springframework/core/codec/ResourceEncoderTests.java new file mode 100644 index 0000000000..b27f987a2a --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/core/codec/ResourceEncoderTests.java @@ -0,0 +1,77 @@ +/* + * Copyright 2002-2016 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.core.codec; + +import java.nio.charset.StandardCharsets; + +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.test.TestSubscriber; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.buffer.AbstractDataBufferAllocatingTestCase; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.MediaType; + +import static org.junit.Assert.assertTrue; + +/** + * @author Arjen Poutsma + */ +public class ResourceEncoderTests extends AbstractDataBufferAllocatingTestCase { + + private final ResourceEncoder encoder = new ResourceEncoder(); + + @Test + public void canEncode() throws Exception { + assertTrue( + this.encoder.canEncode(ResolvableType.forClass(InputStreamResource.class), + MediaType.TEXT_PLAIN)); + assertTrue( + this.encoder.canEncode(ResolvableType.forClass(ByteArrayResource.class), + MediaType.TEXT_PLAIN)); + assertTrue(this.encoder.canEncode(ResolvableType.forClass(Resource.class), + MediaType.TEXT_PLAIN)); + assertTrue( + this.encoder.canEncode(ResolvableType.forClass(InputStreamResource.class), + MediaType.APPLICATION_JSON)); + } + + @Test + public void encode() throws Exception { + String s = "foo"; + Resource resource = new ByteArrayResource(s.getBytes(StandardCharsets.UTF_8)); + + Mono source = Mono.just(resource); + + Flux output = this.encoder.encode(source, this.bufferFactory, + ResolvableType.forClass(Resource.class), + null); + + TestSubscriber + .subscribe(output) + .assertNoError() + .assertComplete() + .assertValuesWith(stringConsumer(s)); + + } + +} \ No newline at end of file diff --git a/spring-web-reactive/src/test/java/org/springframework/core/codec/StringDecoderTests.java b/spring-web-reactive/src/test/java/org/springframework/core/codec/StringDecoderTests.java new file mode 100644 index 0000000000..43492ae6a9 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/core/codec/StringDecoderTests.java @@ -0,0 +1,117 @@ +/* + * Copyright 2002-2016 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.core.codec; + +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.test.TestSubscriber; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.AbstractDataBufferAllocatingTestCase; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.MediaType; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * @author Sebastien Deleuze + * @author Brian Clozel + * @author Mark Paluch + */ +public class StringDecoderTests extends AbstractDataBufferAllocatingTestCase { + + private StringDecoder decoder = new StringDecoder(); + + + @Test + public void canDecode() { + assertTrue(this.decoder.canDecode(ResolvableType.forClass(String.class), MediaType.TEXT_PLAIN)); + assertTrue(this.decoder.canDecode(ResolvableType.forClass(String.class), MediaType.TEXT_HTML)); + assertTrue(this.decoder.canDecode(ResolvableType.forClass(String.class), MediaType.APPLICATION_JSON)); + assertFalse(this.decoder.canDecode(ResolvableType.forClass(Integer.class), MediaType.TEXT_PLAIN)); + assertFalse(this.decoder.canDecode(ResolvableType.forClass(Object.class), MediaType.APPLICATION_JSON)); + } + + @Test + public void decode() throws InterruptedException { + this.decoder = new StringDecoder(false); + Flux source = Flux.just(stringBuffer("foo"), stringBuffer("bar"), stringBuffer("baz")); + Flux output = this.decoder.decode(source, ResolvableType.forClass(String.class), null); + + TestSubscriber.subscribe(output) + .assertNoError() + .assertComplete() + .assertValues("foo", "bar", "baz"); + } + + @Test + public void decodeNewLine() throws InterruptedException { + DataBuffer fooBar = stringBuffer("\nfoo\r\nbar\r"); + DataBuffer baz = stringBuffer("\nbaz"); + Flux source = Flux.just(fooBar, baz); + Flux output = decoder.decode(source, ResolvableType.forClass(String.class), null); + + TestSubscriber.subscribe(output) + .assertNoError() + .assertComplete().assertValues("\n", "foo\r", "\n", "bar\r", "\n", "baz"); + } + + @Test + public void decodeEmptyFlux() throws InterruptedException { + Flux source = Flux.empty(); + Flux output = this.decoder.decode(source, ResolvableType.forClass(String.class), null); + + TestSubscriber.subscribe(output) + .assertNoError() + .assertComplete() + .assertNoValues(); + } + + @Test + public void decodeEmptyString() throws InterruptedException { + Flux source = Flux.just(stringBuffer("")); + Flux output = this.decoder.decode(source, ResolvableType.forClass(String.class), null); + + TestSubscriber.subscribe(output).assertValues(""); + } + + @Test + public void decodeToMono() throws InterruptedException { + this.decoder = new StringDecoder(false); + Flux source = Flux.just(stringBuffer("foo"), stringBuffer("bar"), stringBuffer("baz")); + Mono output = this.decoder.decodeToMono(source, ResolvableType.forClass(String.class), null); + + TestSubscriber.subscribe(output) + .assertNoError() + .assertComplete() + .assertValues("foobarbaz"); + } + + @Test + public void decodeToMonoWithEmptyFlux() throws InterruptedException { + Flux source = Flux.empty(); + Mono output = this.decoder.decodeToMono(source, ResolvableType.forClass(String.class), null); + + TestSubscriber.subscribe(output) + .assertNoError() + .assertComplete() + .assertNoValues(); + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/core/codec/StringEncoderTests.java b/spring-web-reactive/src/test/java/org/springframework/core/codec/StringEncoderTests.java new file mode 100644 index 0000000000..2a4e60f18c --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/core/codec/StringEncoderTests.java @@ -0,0 +1,74 @@ +/* + * Copyright 2002-2016 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.core.codec; + +import java.nio.charset.StandardCharsets; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import reactor.core.publisher.Flux; +import reactor.core.test.TestSubscriber; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.AbstractDataBufferAllocatingTestCase; +import org.springframework.core.io.buffer.support.DataBufferUtils; +import org.springframework.http.MediaType; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * @author Sebastien Deleuze + */ +@RunWith(Parameterized.class) +public class StringEncoderTests extends AbstractDataBufferAllocatingTestCase { + + private StringEncoder encoder; + + @Before + public void createEncoder() { + this.encoder = new StringEncoder(); + } + + @Test + public void canWrite() { + assertTrue(this.encoder + .canEncode(ResolvableType.forClass(String.class), MediaType.TEXT_PLAIN)); + assertFalse(this.encoder + .canEncode(ResolvableType.forClass(Integer.class), MediaType.TEXT_PLAIN)); + assertFalse(this.encoder.canEncode(ResolvableType.forClass(String.class), + MediaType.APPLICATION_JSON)); + } + + @Test + public void write() throws InterruptedException { + Flux output = Flux.from( + this.encoder.encode(Flux.just("foo"), this.bufferFactory, null, null)) + .map(chunk -> { + byte[] b = new byte[chunk.readableByteCount()]; + chunk.read(b); + DataBufferUtils.release(chunk); + return new String(b, StandardCharsets.UTF_8); + }); + TestSubscriber + .subscribe(output) + .assertValues("foo"); + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/core/convert/support/MonoToCompletableFutureConverterTests.java b/spring-web-reactive/src/test/java/org/springframework/core/convert/support/MonoToCompletableFutureConverterTests.java new file mode 100644 index 0000000000..64b33cc9b8 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/core/convert/support/MonoToCompletableFutureConverterTests.java @@ -0,0 +1,56 @@ +/* + * Copyright 2002-2016 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.core.convert.support; + +import java.util.concurrent.CompletableFuture; + +import org.junit.Before; +import org.junit.Test; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Unit tests for {@link ReactorToRxJava1Converter}. + * @author Rossen Stoyanchev + */ +public class MonoToCompletableFutureConverterTests { + + private GenericConversionService conversionService; + + + @Before + public void setUp() throws Exception { + this.conversionService = new GenericConversionService(); + this.conversionService.addConverter(new MonoToCompletableFutureConverter()); + } + + @Test + public void canConvert() throws Exception { + assertTrue(this.conversionService.canConvert(Mono.class, CompletableFuture.class)); + assertTrue(this.conversionService.canConvert(CompletableFuture.class, Mono.class)); + + assertFalse(this.conversionService.canConvert(Flux.class, CompletableFuture.class)); + assertFalse(this.conversionService.canConvert(CompletableFuture.class, Flux.class)); + + assertFalse(this.conversionService.canConvert(Publisher.class, CompletableFuture.class)); + assertFalse(this.conversionService.canConvert(CompletableFuture.class, Publisher.class)); + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/core/convert/support/ReactorToRxJava1ConverterTests.java b/spring-web-reactive/src/test/java/org/springframework/core/convert/support/ReactorToRxJava1ConverterTests.java new file mode 100644 index 0000000000..427e9be4ef --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/core/convert/support/ReactorToRxJava1ConverterTests.java @@ -0,0 +1,69 @@ +/* + * Copyright 2002-2016 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.core.convert.support; + +import org.junit.Before; +import org.junit.Test; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import rx.Completable; +import rx.Observable; +import rx.Single; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Unit tests for {@link ReactorToRxJava1Converter}. + * @author Rossen Stoyanchev + */ +public class ReactorToRxJava1ConverterTests { + + private GenericConversionService conversionService; + + + @Before + public void setUp() throws Exception { + this.conversionService = new GenericConversionService(); + this.conversionService.addConverter(new ReactorToRxJava1Converter()); + } + + @Test + public void canConvert() throws Exception { + assertTrue(this.conversionService.canConvert(Flux.class, Observable.class)); + assertTrue(this.conversionService.canConvert(Observable.class, Flux.class)); + + assertTrue(this.conversionService.canConvert(Mono.class, Single.class)); + assertTrue(this.conversionService.canConvert(Single.class, Mono.class)); + + assertTrue(this.conversionService.canConvert(Mono.class, Completable.class)); + assertTrue(this.conversionService.canConvert(Completable.class, Mono.class)); + + assertFalse(this.conversionService.canConvert(Flux.class, Single.class)); + assertFalse(this.conversionService.canConvert(Single.class, Flux.class)); + + assertFalse(this.conversionService.canConvert(Flux.class, Completable.class)); + assertFalse(this.conversionService.canConvert(Completable.class, Flux.class)); + + assertFalse(this.conversionService.canConvert(Mono.class, Observable.class)); + assertFalse(this.conversionService.canConvert(Observable.class, Mono.class)); + + assertFalse(this.conversionService.canConvert(Publisher.class, Observable.class)); + assertFalse(this.conversionService.canConvert(Observable.class, Publisher.class)); + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/core/io/buffer/AbstractDataBufferAllocatingTestCase.java b/spring-web-reactive/src/test/java/org/springframework/core/io/buffer/AbstractDataBufferAllocatingTestCase.java new file mode 100644 index 0000000000..1ec7c9990a --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/core/io/buffer/AbstractDataBufferAllocatingTestCase.java @@ -0,0 +1,79 @@ +/* + * Copyright 2002-2016 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.core.io.buffer; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.function.Consumer; + +import io.netty.buffer.PooledByteBufAllocator; +import io.netty.buffer.UnpooledByteBufAllocator; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import org.springframework.core.io.buffer.support.DataBufferTestUtils; +import org.springframework.core.io.buffer.support.DataBufferUtils; + +import static org.junit.Assert.assertEquals; + +/** + * @author Arjen Poutsma + */ +@RunWith(Parameterized.class) +public abstract class AbstractDataBufferAllocatingTestCase { + + @Parameterized.Parameter + public DataBufferFactory bufferFactory; + + @Parameterized.Parameters(name = "{0}") + public static Object[][] dataBufferFactories() { + return new Object[][]{ + {new NettyDataBufferFactory(new UnpooledByteBufAllocator(true))}, + {new NettyDataBufferFactory(new UnpooledByteBufAllocator(false))}, + {new NettyDataBufferFactory(new PooledByteBufAllocator(true))}, + {new NettyDataBufferFactory(new PooledByteBufAllocator(false))}, + {new DefaultDataBufferFactory(true)}, + {new DefaultDataBufferFactory(false)} + + }; + } + + protected DataBuffer createDataBuffer(int capacity) { + return this.bufferFactory.allocateBuffer(capacity); + } + + protected DataBuffer stringBuffer(String value) { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + DataBuffer buffer = this.bufferFactory.allocateBuffer(bytes.length); + buffer.write(bytes); + return buffer; + } + + protected void release(DataBuffer... buffers) { + Arrays.stream(buffers).forEach(DataBufferUtils::release); + } + + protected Consumer stringConsumer(String expected) { + return dataBuffer -> { + String value = + DataBufferTestUtils.dumpString(dataBuffer, StandardCharsets.UTF_8); + assertEquals(expected, value); + DataBufferUtils.release(dataBuffer); + }; + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/core/io/buffer/DataBufferTests.java b/spring-web-reactive/src/test/java/org/springframework/core/io/buffer/DataBufferTests.java new file mode 100644 index 0000000000..df4a02d789 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/core/io/buffer/DataBufferTests.java @@ -0,0 +1,260 @@ +/* + * Copyright 2002-2016 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.core.io.buffer; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.util.Arrays; + +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * @author Arjen Poutsma + */ +public class DataBufferTests extends AbstractDataBufferAllocatingTestCase { + + @Test + public void writeAndRead() { + + DataBuffer buffer = createDataBuffer(5); + buffer.write(new byte[]{'a', 'b', 'c'}); + + int ch = buffer.read(); + assertEquals('a', ch); + + buffer.write((byte) 'd'); + buffer.write((byte) 'e'); + + byte[] result = new byte[4]; + buffer.read(result); + + assertArrayEquals(new byte[]{'b', 'c', 'd', 'e'}, result); + + release(buffer); + } + + @Test + public void inputStream() throws IOException { + byte[] data = new byte[]{'a', 'b', 'c', 'd', 'e'}; + + DataBuffer buffer = createDataBuffer(4); + buffer.write(data); + + buffer.read(); // readIndex++ + + InputStream inputStream = buffer.asInputStream(); + + int available = inputStream.available(); + assertEquals(4, available); + + int result = inputStream.read(); + assertEquals('b', result); + + available = inputStream.available(); + assertEquals(3, available); + + byte[] bytes = new byte[2]; + int len = inputStream.read(bytes); + assertEquals(2, len); + assertArrayEquals(new byte[]{'c', 'd'}, bytes); + + Arrays.fill(bytes, (byte) 0); + len = inputStream.read(bytes); + assertEquals(1, len); + assertArrayEquals(new byte[]{'e', (byte) 0}, bytes); + + release(buffer); + } + + @Test + public void outputStream() throws IOException { + DataBuffer buffer = createDataBuffer(4); + buffer.write((byte) 'a'); + + OutputStream outputStream = buffer.asOutputStream(); + outputStream.write(new byte[]{'b', 'c', 'd'}); + + buffer.write((byte) 'e'); + + byte[] bytes = new byte[5]; + buffer.read(bytes); + assertArrayEquals(new byte[]{'a', 'b', 'c', 'd', 'e'}, bytes); + + release(buffer); + } + + @Test + public void expand() { + DataBuffer buffer = createDataBuffer(1); + buffer.write((byte) 'a'); + buffer.write((byte) 'b'); + + byte[] result = new byte[2]; + buffer.read(result); + assertArrayEquals(new byte[]{'a', 'b'}, result); + + buffer.write(new byte[]{'c', 'd'}); + + result = new byte[2]; + buffer.read(result); + assertArrayEquals(new byte[]{'c', 'd'}, result); + + release(buffer); + } + + @Test + public void writeByteBuffer() { + DataBuffer buffer1 = createDataBuffer(1); + buffer1.write((byte) 'a'); + ByteBuffer buffer2 = createByteBuffer(2); + buffer2.put((byte) 'b'); + buffer2.flip(); + ByteBuffer buffer3 = createByteBuffer(3); + buffer3.put((byte) 'c'); + buffer3.flip(); + + buffer1.write(buffer2, buffer3); + buffer1.write((byte) 'd'); // make sure the write index is correctly set + + assertEquals(4, buffer1.readableByteCount()); + byte[] result = new byte[4]; + buffer1.read(result); + + assertArrayEquals(new byte[]{'a', 'b', 'c', 'd'}, result); + + release(buffer1); + } + + private ByteBuffer createByteBuffer(int capacity) { + return ByteBuffer.allocate(capacity); + } + + @Test + public void writeDataBuffer() { + DataBuffer buffer1 = createDataBuffer(1); + buffer1.write((byte) 'a'); + DataBuffer buffer2 = createDataBuffer(2); + buffer2.write((byte) 'b'); + DataBuffer buffer3 = createDataBuffer(3); + buffer3.write((byte) 'c'); + + buffer1.write(buffer2, buffer3); + buffer1.write((byte) 'd'); // make sure the write index is correctly set + + assertEquals(4, buffer1.readableByteCount()); + byte[] result = new byte[4]; + buffer1.read(result); + + assertArrayEquals(new byte[]{'a', 'b', 'c', 'd'}, result); + + release(buffer1); + } + + @Test + public void asByteBuffer() { + DataBuffer buffer = createDataBuffer(4); + buffer.write(new byte[]{'a', 'b', 'c'}); + buffer.read(); // skip a + + ByteBuffer result = buffer.asByteBuffer(); + + buffer.write((byte) 'd'); + assertEquals(2, result.remaining()); + byte[] resultBytes = new byte[2]; + buffer.read(resultBytes); + assertArrayEquals(new byte[]{'b', 'c'}, resultBytes); + + release(buffer); + } + + @Test + public void indexOf() { + DataBuffer buffer = createDataBuffer(3); + buffer.write(new byte[]{'a', 'b', 'c'}); + + int result = buffer.indexOf(b -> b == 'c', 0); + assertEquals(2, result); + + result = buffer.indexOf(b -> b == 'c', Integer.MIN_VALUE); + assertEquals(2, result); + + result = buffer.indexOf(b -> b == 'c', Integer.MAX_VALUE); + assertEquals(-1, result); + + result = buffer.indexOf(b -> b == 'z', 0); + assertEquals(-1, result); + + release(buffer); + } + + @Test + public void lastIndexOf() { + DataBuffer buffer = createDataBuffer(3); + buffer.write(new byte[]{'a', 'b', 'c'}); + + int result = buffer.lastIndexOf(b -> b == 'b', 3); + assertEquals(1, result); + + result = buffer.lastIndexOf(b -> b == 'b', Integer.MAX_VALUE); + assertEquals(1, result); + + result = buffer.lastIndexOf(b -> b == 'b', Integer.MIN_VALUE); + assertEquals(-1, result); + + result = buffer.lastIndexOf(b -> b == 'z', 0); + assertEquals(-1, result); + + release(buffer); + } + + @Test + public void slice() { + DataBuffer buffer = createDataBuffer(3); + buffer.write(new byte[]{'a', 'b'}); + + DataBuffer slice = buffer.slice(1, 2); + assertEquals(2, slice.readableByteCount()); + try { + slice.write((byte) 0); + fail("IndexOutOfBoundsException expected"); + } + catch (Exception ignored) { + } + buffer.write((byte) 'c'); + + assertEquals(3, buffer.readableByteCount()); + byte[] result = new byte[3]; + buffer.read(result); + + assertArrayEquals(new byte[]{'a', 'b', 'c'}, result); + + assertEquals(2, slice.readableByteCount()); + result = new byte[2]; + slice.read(result); + + assertArrayEquals(new byte[]{'b', 'c'}, result); + + + release(buffer); + } + + +} \ No newline at end of file diff --git a/spring-web-reactive/src/test/java/org/springframework/core/io/buffer/PooledDataBufferTests.java b/spring-web-reactive/src/test/java/org/springframework/core/io/buffer/PooledDataBufferTests.java new file mode 100644 index 0000000000..1acc24fa0e --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/core/io/buffer/PooledDataBufferTests.java @@ -0,0 +1,73 @@ +/* + * Copyright 2002-2016 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.core.io.buffer; + +import io.netty.buffer.PooledByteBufAllocator; +import io.netty.buffer.UnpooledByteBufAllocator; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * @author Arjen Poutsma + */ +@RunWith(Parameterized.class) +public class PooledDataBufferTests { + + @Parameterized.Parameter + public DataBufferFactory dataBufferFactory; + + @Parameterized.Parameters(name = "{0}") + public static Object[][] buffers() { + + return new Object[][]{ + {new NettyDataBufferFactory(new UnpooledByteBufAllocator(true))}, + {new NettyDataBufferFactory(new UnpooledByteBufAllocator(false))}, + {new NettyDataBufferFactory(new PooledByteBufAllocator(true))}, + {new NettyDataBufferFactory(new PooledByteBufAllocator(false))}}; + } + + private PooledDataBuffer createDataBuffer(int capacity) { + return (PooledDataBuffer) dataBufferFactory.allocateBuffer(capacity); + } + + @Test + public void retainAndRelease() { + PooledDataBuffer buffer = createDataBuffer(1); + buffer.write((byte) 'a'); + + buffer.retain(); + boolean result = buffer.release(); + assertFalse(result); + result = buffer.release(); + assertTrue(result); + } + + @Test(expected = IllegalStateException.class) + public void tooManyReleases() { + PooledDataBuffer buffer = createDataBuffer(1); + buffer.write((byte) 'a'); + + buffer.release(); + buffer.release(); + } + + +} \ No newline at end of file diff --git a/spring-web-reactive/src/test/java/org/springframework/core/io/buffer/support/DataBufferTestUtils.java b/spring-web-reactive/src/test/java/org/springframework/core/io/buffer/support/DataBufferTestUtils.java new file mode 100644 index 0000000000..9d7cecd137 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/core/io/buffer/support/DataBufferTestUtils.java @@ -0,0 +1,65 @@ +/* + * Copyright 2002-2016 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.core.io.buffer.support; + +import java.nio.charset.Charset; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.util.Assert; + +/** + * Utility class for working with {@link DataBuffer}s in tests. + * + *

Note that this class is in the {@code test} tree of the project: the methods + * contained herein are not suitable for production code bases. + * + * @author Arjen Poutsma + */ +public abstract class DataBufferTestUtils { + + /** + * Dumps all the bytes in the given data buffer, and returns them as a byte array. + * + *

Note that this method reads the entire buffer into the heap, which might + * consume a lot of memory. + * @param buffer the data buffer to dump the bytes of + * @return the bytes in the given data buffer + */ + public static byte[] dumpBytes(DataBuffer buffer) { + Assert.notNull(buffer, "'buffer' must not be null"); + + byte[] bytes = new byte[buffer.readableByteCount()]; + buffer.read(bytes); + return bytes; + } + + /** + * Dumps all the bytes in the given data buffer, and returns them as a string. + * + *

Note that this method reads the entire buffer into the heap, which might + * consume a lot of memory. + * @param buffer the data buffer to dump the string contents of + * @param charset the charset of the data + * @return the string representation of the given data buffer + */ + public static String dumpString(DataBuffer buffer, Charset charset) { + Assert.notNull(charset, "'charset' must not be null"); + + byte[] bytes = dumpBytes(buffer); + return new String(bytes, charset); + } +} diff --git a/spring-web-reactive/src/test/java/org/springframework/core/io/buffer/support/DataBufferTestUtilsTests.java b/spring-web-reactive/src/test/java/org/springframework/core/io/buffer/support/DataBufferTestUtilsTests.java new file mode 100644 index 0000000000..1763f4bbce --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/core/io/buffer/support/DataBufferTestUtilsTests.java @@ -0,0 +1,60 @@ +/* + * Copyright 2002-2016 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.core.io.buffer.support; + +import java.nio.charset.StandardCharsets; + +import org.junit.Test; + +import org.springframework.core.io.buffer.AbstractDataBufferAllocatingTestCase; +import org.springframework.core.io.buffer.DataBuffer; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; + +/** + * @author Arjen Poutsma + */ +public class DataBufferTestUtilsTests extends AbstractDataBufferAllocatingTestCase { + + @Test + public void dumpBytes() { + DataBuffer buffer = this.bufferFactory.allocateBuffer(4); + byte[] source = {'a', 'b', 'c', 'd'}; + buffer.write(source); + + byte[] result = DataBufferTestUtils.dumpBytes(buffer); + + assertArrayEquals(source, result); + + release(buffer); + } + + @Test + public void dumpString() { + DataBuffer buffer = this.bufferFactory.allocateBuffer(4); + String source = "abcd"; + buffer.write(source.getBytes(StandardCharsets.UTF_8)); + + String result = DataBufferTestUtils.dumpString(buffer, StandardCharsets.UTF_8); + + assertEquals(source, result); + + release(buffer); + } + +} \ No newline at end of file diff --git a/spring-web-reactive/src/test/java/org/springframework/core/io/buffer/support/DataBufferUtilsTests.java b/spring-web-reactive/src/test/java/org/springframework/core/io/buffer/support/DataBufferUtilsTests.java new file mode 100644 index 0000000000..51dc81833f --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/core/io/buffer/support/DataBufferUtilsTests.java @@ -0,0 +1,112 @@ +/* + * Copyright 2002-2016 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.core.io.buffer.support; + +import java.io.InputStream; +import java.net.URI; +import java.nio.channels.FileChannel; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; + +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.test.TestSubscriber; + +import org.springframework.core.io.buffer.AbstractDataBufferAllocatingTestCase; +import org.springframework.core.io.buffer.DataBuffer; + +import static org.junit.Assert.assertFalse; + +/** + * @author Arjen Poutsma + */ +public class DataBufferUtilsTests extends AbstractDataBufferAllocatingTestCase { + + @Test + public void readChannel() throws Exception { + URI uri = DataBufferUtilsTests.class.getResource("DataBufferUtilsTests.txt") + .toURI(); + FileChannel channel = FileChannel.open(Paths.get(uri), StandardOpenOption.READ); + + Flux flux = DataBufferUtils.read(channel, this.bufferFactory, 4); + + TestSubscriber + .subscribe(flux) + .assertNoError() + .assertComplete() + .assertValuesWith( + stringConsumer("foo\n"), stringConsumer("bar\n"), + stringConsumer("baz\n"), stringConsumer("qux\n")); + + assertFalse(channel.isOpen()); + } + + @Test + public void readUnalignedChannel() throws Exception { + URI uri = DataBufferUtilsTests.class.getResource("DataBufferUtilsTests.txt") + .toURI(); + FileChannel channel = FileChannel.open(Paths.get(uri), StandardOpenOption.READ); + + Flux flux = DataBufferUtils.read(channel, this.bufferFactory, 3); + + TestSubscriber + .subscribe(flux) + .assertNoError() + .assertComplete() + .assertValuesWith( + stringConsumer("foo"), stringConsumer("\nba"), + stringConsumer("r\nb"), stringConsumer("az\n"), + stringConsumer("qux"), stringConsumer("\n")); + + assertFalse(channel.isOpen()); + } + + @Test + public void readInputStream() { + InputStream is = DataBufferUtilsTests.class + .getResourceAsStream("DataBufferUtilsTests.txt"); + + Flux flux = DataBufferUtils.read(is, this.bufferFactory, 4); + + TestSubscriber + .subscribe(flux) + .assertNoError() + .assertComplete() + .assertValuesWith( + stringConsumer("foo\n"), stringConsumer("bar\n"), + stringConsumer("baz\n"), stringConsumer("qux\n")); + } + + @Test + public void takeUntilByteCount() { + DataBuffer foo = stringBuffer("foo"); + DataBuffer bar = stringBuffer("bar"); + DataBuffer baz = stringBuffer("baz"); + Flux flux = Flux.just(foo, bar, baz); + + Flux result = DataBufferUtils.takeUntilByteCount(flux, 5L); + + TestSubscriber + .subscribe(result) + .assertNoError() + .assertComplete() + .assertValuesWith(stringConsumer("foo"), stringConsumer("ba")); + + release(baz); + } + +} \ No newline at end of file diff --git a/spring-web-reactive/src/test/java/org/springframework/http/codec/Pojo.java b/spring-web-reactive/src/test/java/org/springframework/http/codec/Pojo.java new file mode 100644 index 0000000000..ff4cdc76c4 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/http/codec/Pojo.java @@ -0,0 +1,76 @@ +/* + * Copyright 2002-2016 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.codec; + +import javax.xml.bind.annotation.XmlRootElement; + +/** + * @author Sebastien Deleuze + */ +@XmlRootElement +public class Pojo { + + private String foo; + + private String bar; + + public Pojo() { + } + + public Pojo(String foo, String bar) { + this.foo = foo; + this.bar = bar; + } + + public String getFoo() { + return this.foo; + } + + public void setFoo(String foo) { + this.foo = foo; + } + + public String getBar() { + return this.bar; + } + + public void setBar(String bar) { + this.bar = bar; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o instanceof Pojo) { + Pojo other = (Pojo) o; + return this.foo.equals(other.foo) && this.bar.equals(other.bar); + } + return false; + } + + @Override + public int hashCode() { + return 31 * foo.hashCode() + bar.hashCode(); + } + + @Override + public String toString() { + return "Pojo[foo='" + this.foo + "\'" + ", bar='" + this.bar + "\']"; + } +} diff --git a/spring-web-reactive/src/test/java/org/springframework/http/codec/SseEventEncoderTests.java b/spring-web-reactive/src/test/java/org/springframework/http/codec/SseEventEncoderTests.java new file mode 100644 index 0000000000..bb5b0996a7 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/http/codec/SseEventEncoderTests.java @@ -0,0 +1,144 @@ +/* + * Copyright 2002-2016 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.codec; + +import java.util.Arrays; + +import static org.junit.Assert.*; +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.test.TestSubscriber; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.AbstractDataBufferAllocatingTestCase; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.FlushingDataBuffer; +import org.springframework.http.codec.json.JacksonJsonEncoder; +import org.springframework.util.MimeType; +import org.springframework.web.reactive.sse.SseEvent; + +/** + * @author Sebastien Deleuze + */ +public class SseEventEncoderTests extends AbstractDataBufferAllocatingTestCase { + + @Test + public void nullMimeType() { + SseEventEncoder encoder = new SseEventEncoder(Arrays.asList(new JacksonJsonEncoder())); + assertTrue(encoder.canEncode(ResolvableType.forClass(Object.class), null)); + } + + @Test + public void unsupportedMimeType() { + SseEventEncoder encoder = new SseEventEncoder(Arrays.asList(new JacksonJsonEncoder())); + assertFalse(encoder.canEncode(ResolvableType.forClass(Object.class), new MimeType("foo", "bar"))); + } + + @Test + public void supportedMimeType() { + SseEventEncoder encoder = new SseEventEncoder(Arrays.asList(new JacksonJsonEncoder())); + assertTrue(encoder.canEncode(ResolvableType.forClass(Object.class), new MimeType("text", "event-stream"))); + } + + @Test + public void encodeServerSentEvent() { + SseEventEncoder encoder = new SseEventEncoder(Arrays.asList(new JacksonJsonEncoder())); + SseEvent event = new SseEvent(); + event.setId("c42"); + event.setName("foo"); + event.setComment("bla\nbla bla\nbla bla bla"); + event.setReconnectTime(123L); + Mono source = Mono.just(event); + Flux output = encoder.encode(source, this.bufferFactory, + ResolvableType.forClass(SseEvent.class), new MimeType("text", "event-stream")); + TestSubscriber + .subscribe(output) + .assertNoError() + .assertValuesWith( + stringConsumer( + "id:c42\n" + + "event:foo\n" + + "retry:123\n" + + ":bla\n:bla bla\n:bla bla bla\n"), + stringConsumer("\n"), + b -> assertEquals(FlushingDataBuffer.class, b.getClass()) + ); + } + + @Test + public void encodeString() { + SseEventEncoder encoder = new SseEventEncoder(Arrays.asList(new JacksonJsonEncoder())); + Flux source = Flux.just("foo", "bar"); + Flux output = encoder.encode(source, this.bufferFactory, + ResolvableType.forClass(String.class), new MimeType("text", "event-stream")); + TestSubscriber + .subscribe(output) + .assertNoError() + .assertValuesWith( + stringConsumer("data:foo\n"), + stringConsumer("\n"), + b -> assertEquals(FlushingDataBuffer.class, b.getClass()), + stringConsumer("data:bar\n"), + stringConsumer("\n"), + b -> assertEquals(FlushingDataBuffer.class, b.getClass()) + ); + } + + @Test + public void encodeMultilineString() { + SseEventEncoder encoder = new SseEventEncoder(Arrays.asList(new JacksonJsonEncoder())); + Flux source = Flux.just("foo\nbar", "foo\nbaz"); + Flux output = encoder.encode(source, this.bufferFactory, + ResolvableType.forClass(String.class), new MimeType("text", "event-stream")); + TestSubscriber + .subscribe(output) + .assertNoError() + .assertValuesWith( + stringConsumer("data:foo\ndata:bar\n"), + stringConsumer("\n"), + b -> assertEquals(FlushingDataBuffer.class, b.getClass()), + stringConsumer("data:foo\ndata:baz\n"), + stringConsumer("\n"), + b -> assertEquals(FlushingDataBuffer.class, b.getClass()) + ); + } + + @Test + public void encodePojo() { + SseEventEncoder encoder = new SseEventEncoder(Arrays.asList(new JacksonJsonEncoder())); + Flux source = Flux.just(new Pojo("foofoo", "barbar"), new Pojo("foofoofoo", "barbarbar")); + Flux output = encoder.encode(source, this.bufferFactory, + ResolvableType.forClass(Pojo.class), new MimeType("text", "event-stream")); + TestSubscriber + .subscribe(output) + .assertNoError() + .assertValuesWith( + stringConsumer("data:"), + stringConsumer("{\"foo\":\"foofoo\",\"bar\":\"barbar\"}"), + stringConsumer("\n"), + stringConsumer("\n"), + b -> assertEquals(FlushingDataBuffer.class, b.getClass()), + stringConsumer("data:"), + stringConsumer("{\"foo\":\"foofoofoo\",\"bar\":\"barbarbar\"}"), + stringConsumer("\n"), + stringConsumer("\n"), + b -> assertEquals(FlushingDataBuffer.class, b.getClass()) + ); + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/http/codec/json/JacksonJsonDecoderTests.java b/spring-web-reactive/src/test/java/org/springframework/http/codec/json/JacksonJsonDecoderTests.java new file mode 100644 index 0000000000..99aacd522c --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/http/codec/json/JacksonJsonDecoderTests.java @@ -0,0 +1,92 @@ +/* + * Copyright 2002-2016 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.codec.json; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.List; + +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.test.TestSubscriber; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.AbstractDataBufferAllocatingTestCase; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.MediaType; +import org.springframework.http.codec.Pojo; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Unit tests for {@link JacksonJsonDecoder}. + * @author Sebastien Deleuze + * @author Rossen Stoyanchev + */ +public class JacksonJsonDecoderTests extends AbstractDataBufferAllocatingTestCase { + + @Test + public void canDecode() { + JacksonJsonDecoder decoder = new JacksonJsonDecoder(); + + assertTrue(decoder.canDecode(null, MediaType.APPLICATION_JSON)); + assertFalse(decoder.canDecode(null, MediaType.APPLICATION_XML)); + } + + @Test + public void decodePojo() { + Flux source = Flux.just(stringBuffer("{\"foo\": \"foofoo\", \"bar\": \"barbar\"}")); + ResolvableType elementType = ResolvableType.forClass(Pojo.class); + Flux flux = new JacksonJsonDecoder().decode(source, elementType, null); + + TestSubscriber.subscribe(flux).assertNoError().assertComplete(). + assertValues(new Pojo("foofoo", "barbar")); + } + + @Test + public void decodeToList() throws Exception { + Flux source = Flux.just(stringBuffer( + "[{\"bar\":\"b1\",\"foo\":\"f1\"},{\"bar\":\"b2\",\"foo\":\"f2\"}]")); + + Method method = getClass().getDeclaredMethod("handle", List.class); + ResolvableType elementType = ResolvableType.forMethodParameter(method, 0); + Mono mono = new JacksonJsonDecoder().decodeToMono(source, elementType, null); + + TestSubscriber.subscribe(mono).assertNoError().assertComplete(). + assertValues(Arrays.asList(new Pojo("f1", "b1"), new Pojo("f2", "b2"))); + } + + @Test + public void decodeToFlux() throws Exception { + Flux source = Flux.just(stringBuffer( + "[{\"bar\":\"b1\",\"foo\":\"f1\"},{\"bar\":\"b2\",\"foo\":\"f2\"}]")); + + ResolvableType elementType = ResolvableType.forClass(Pojo.class); + Flux flux = new JacksonJsonDecoder().decode(source, elementType, null); + + TestSubscriber.subscribe(flux).assertNoError().assertComplete(). + assertValues(new Pojo("f1", "b1"), new Pojo("f2", "b2")); + } + + @SuppressWarnings("unused") + void handle(List list) { + } + + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/http/codec/json/JacksonJsonEncoderTests.java b/spring-web-reactive/src/test/java/org/springframework/http/codec/json/JacksonJsonEncoderTests.java new file mode 100644 index 0000000000..b7249a0331 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/http/codec/json/JacksonJsonEncoderTests.java @@ -0,0 +1,108 @@ +/* + * Copyright 2002-2016 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.codec.json; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeName; +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.test.TestSubscriber; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.AbstractDataBufferAllocatingTestCase; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.MediaType; +import org.springframework.http.codec.Pojo; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * @author Sebastien Deleuze + */ +public class JacksonJsonEncoderTests extends AbstractDataBufferAllocatingTestCase { + + private JacksonJsonEncoder encoder; + + + @Before + public void createEncoder() { + this.encoder = new JacksonJsonEncoder(); + } + + + @Test + public void canEncode() { + assertTrue(this.encoder.canEncode(null, MediaType.APPLICATION_JSON)); + assertFalse(this.encoder.canEncode(null, MediaType.APPLICATION_XML)); + } + + @Test + public void encode() { + Flux source = Flux.just( + new Pojo("foo", "bar"), + new Pojo("foofoo", "barbar"), + new Pojo("foofoofoo", "barbarbar") + ); + ResolvableType type = ResolvableType.forClass(Pojo.class); + Flux output = this.encoder.encode(source, this.bufferFactory, type, null); + + TestSubscriber.subscribe(output) + .assertComplete() + .assertNoError() + .assertValuesWith( + stringConsumer("["), + stringConsumer("{\"foo\":\"foo\",\"bar\":\"bar\"}"), + stringConsumer(","), + stringConsumer("{\"foo\":\"foofoo\",\"bar\":\"barbar\"}"), + stringConsumer(","), + stringConsumer("{\"foo\":\"foofoofoo\",\"bar\":\"barbarbar\"}"), + stringConsumer("]") + ); + } + + @Test + public void encodeWithType() { + Flux source = Flux.just(new Foo(), new Bar()); + ResolvableType type = ResolvableType.forClass(ParentClass.class); + Flux output = this.encoder.encode(source, this.bufferFactory, type, null); + + TestSubscriber.subscribe(output) + .assertComplete() + .assertNoError() + .assertValuesWith(stringConsumer("["), + stringConsumer("{\"type\":\"foo\"}"), + stringConsumer(","), + stringConsumer("{\"type\":\"bar\"}"), + stringConsumer("]")); + } + + + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") + private static class ParentClass { + } + + @JsonTypeName("foo") + private static class Foo extends ParentClass { + } + + @JsonTypeName("bar") + private static class Bar extends ParentClass { + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/http/codec/json/JsonObjectDecoderTests.java b/spring-web-reactive/src/test/java/org/springframework/http/codec/json/JsonObjectDecoderTests.java new file mode 100644 index 0000000000..6770899ce1 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/http/codec/json/JsonObjectDecoderTests.java @@ -0,0 +1,91 @@ +/* + * Copyright 2002-2016 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.codec.json; + +import java.nio.charset.StandardCharsets; + +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.test.TestSubscriber; + +import org.springframework.core.io.buffer.AbstractDataBufferAllocatingTestCase; +import org.springframework.core.io.buffer.DataBuffer; + +/** + * @author Sebastien Deleuze + */ +public class JsonObjectDecoderTests extends AbstractDataBufferAllocatingTestCase { + + + @Test + public void decodeSingleChunkToJsonObject() { + JsonObjectDecoder decoder = new JsonObjectDecoder(); + Flux source = + Flux.just(stringBuffer("{\"foo\": \"foofoo\", \"bar\": \"barbar\"}")); + Flux output = + decoder.decode(source, null, null).map(JsonObjectDecoderTests::toString); + TestSubscriber + .subscribe(output) + .assertValues("{\"foo\": \"foofoo\", \"bar\": \"barbar\"}"); + } + + @Test + public void decodeMultipleChunksToJsonObject() throws InterruptedException { + JsonObjectDecoder decoder = new JsonObjectDecoder(); + Flux source = Flux.just(stringBuffer("{\"foo\": \"foofoo\""), + stringBuffer(", \"bar\": \"barbar\"}")); + Flux output = + decoder.decode(source, null, null).map(JsonObjectDecoderTests::toString); + TestSubscriber + .subscribe(output) + .assertValues("{\"foo\": \"foofoo\", \"bar\": \"barbar\"}"); + } + + @Test + public void decodeSingleChunkToArray() throws InterruptedException { + JsonObjectDecoder decoder = new JsonObjectDecoder(); + Flux source = Flux.just(stringBuffer( + "[{\"foo\": \"foofoo\", \"bar\": \"barbar\"},{\"foo\": \"foofoofoo\", \"bar\": \"barbarbar\"}]")); + Flux output = + decoder.decode(source, null, null).map(JsonObjectDecoderTests::toString); + TestSubscriber + .subscribe(output) + .assertValues("{\"foo\": \"foofoo\", \"bar\": \"barbar\"}", + "{\"foo\": \"foofoofoo\", \"bar\": \"barbarbar\"}"); + } + + @Test + public void decodeMultipleChunksToArray() throws InterruptedException { + JsonObjectDecoder decoder = new JsonObjectDecoder(); + Flux source = + Flux.just(stringBuffer("[{\"foo\": \"foofoo\", \"bar\""), stringBuffer( + ": \"barbar\"},{\"foo\": \"foofoofoo\", \"bar\": \"barbarbar\"}]")); + Flux output = + decoder.decode(source, null, null).map(JsonObjectDecoderTests::toString); + TestSubscriber + .subscribe(output) + .assertValues("{\"foo\": \"foofoo\", \"bar\": \"barbar\"}", + "{\"foo\": \"foofoofoo\", \"bar\": \"barbarbar\"}"); + } + + private static String toString(DataBuffer buffer) { + byte[] b = new byte[buffer.readableByteCount()]; + buffer.read(b); + return new String(b, StandardCharsets.UTF_8); + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/http/codec/xml/Jaxb2DecoderTests.java b/spring-web-reactive/src/test/java/org/springframework/http/codec/xml/Jaxb2DecoderTests.java new file mode 100644 index 0000000000..5cfbe1945d --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/http/codec/xml/Jaxb2DecoderTests.java @@ -0,0 +1,278 @@ +/* + * Copyright 2002-2016 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.codec.xml; + +import java.util.List; +import javax.xml.namespace.QName; +import javax.xml.stream.events.XMLEvent; + +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.test.TestSubscriber; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.AbstractDataBufferAllocatingTestCase; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.MediaType; +import org.springframework.http.codec.Pojo; +import org.springframework.http.codec.xml.jaxb.XmlRootElement; +import org.springframework.http.codec.xml.jaxb.XmlRootElementWithName; +import org.springframework.http.codec.xml.jaxb.XmlRootElementWithNameAndNamespace; +import org.springframework.http.codec.xml.jaxb.XmlType; +import org.springframework.http.codec.xml.jaxb.XmlTypeWithName; +import org.springframework.http.codec.xml.jaxb.XmlTypeWithNameAndNamespace; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * @author Sebastien Deleuze + */ +public class Jaxb2DecoderTests extends AbstractDataBufferAllocatingTestCase { + + private static final String POJO_ROOT = "" + + "" + + "foofoo" + + "barbar" + + ""; + + private static final String POJO_CHILD = + "" + + "" + + "" + + "foo" + + "bar" + + "" + + "" + + "foofoo" + + "barbar" + + "" + + ""; + + private final Jaxb2Decoder decoder = new Jaxb2Decoder(); + + private final XmlEventDecoder xmlEventDecoder = new XmlEventDecoder(); + + + @Test + public void canDecode() { + assertTrue(this.decoder.canDecode(ResolvableType.forClass(Pojo.class), + MediaType.APPLICATION_XML)); + assertTrue(this.decoder.canDecode(ResolvableType.forClass(Pojo.class), + MediaType.TEXT_XML)); + assertFalse(this.decoder.canDecode(ResolvableType.forClass(Pojo.class), + MediaType.APPLICATION_JSON)); + + assertTrue(this.decoder.canDecode(ResolvableType.forClass(TypePojo.class), + MediaType.APPLICATION_XML)); + + assertFalse(this.decoder.canDecode(ResolvableType.forClass(getClass()), + MediaType.APPLICATION_XML)); + } + + @Test + public void splitOneBranches() { + Flux xmlEvents = this.xmlEventDecoder + .decode(Flux.just(stringBuffer(POJO_ROOT)), null, null); + Flux> result = this.decoder.split(xmlEvents, new QName("pojo")); + + TestSubscriber + .subscribe(result) + .assertNoError() + .assertComplete() + .assertValuesWith(events -> { + assertEquals(8, events.size()); + assertStartElement(events.get(0), "pojo"); + assertStartElement(events.get(1), "foo"); + assertCharacters(events.get(2), "foofoo"); + assertEndElement(events.get(3), "foo"); + assertStartElement(events.get(4), "bar"); + assertCharacters(events.get(5), "barbar"); + assertEndElement(events.get(6), "bar"); + assertEndElement(events.get(7), "pojo"); + }); + + + } + + @Test + public void splitMultipleBranches() { + Flux xmlEvents = this.xmlEventDecoder + .decode(Flux.just(stringBuffer(POJO_CHILD)), null, null); + Flux> result = this.decoder.split(xmlEvents, new QName("pojo")); + + TestSubscriber + .subscribe(result) + .assertNoError() + .assertComplete() + .assertValuesWith(events -> { + assertEquals(8, events.size()); + assertStartElement(events.get(0), "pojo"); + assertStartElement(events.get(1), "foo"); + assertCharacters(events.get(2), "foo"); + assertEndElement(events.get(3), "foo"); + assertStartElement(events.get(4), "bar"); + assertCharacters(events.get(5), "bar"); + assertEndElement(events.get(6), "bar"); + assertEndElement(events.get(7), "pojo"); + }, events -> { + assertEquals(8, events.size()); + assertStartElement(events.get(0), "pojo"); + assertStartElement(events.get(1), "foo"); + assertCharacters(events.get(2), "foofoo"); + assertEndElement(events.get(3), "foo"); + assertStartElement(events.get(4), "bar"); + assertCharacters(events.get(5), "barbar"); + assertEndElement(events.get(6), "bar"); + assertEndElement(events.get(7), "pojo"); + }); + } + + private static void assertStartElement(XMLEvent event, String expectedLocalName) { + assertTrue(event.isStartElement()); + assertEquals(expectedLocalName, event.asStartElement().getName().getLocalPart()); + } + + private static void assertEndElement(XMLEvent event, String expectedLocalName) { + assertTrue(event.isEndElement()); + assertEquals(expectedLocalName, event.asEndElement().getName().getLocalPart()); + } + + private static void assertCharacters(XMLEvent event, String expectedData) { + assertTrue(event.isCharacters()); + assertEquals(expectedData, event.asCharacters().getData()); + } + + @Test + public void decodeSingleXmlRootElement() throws Exception { + Flux source = Flux.just(stringBuffer(POJO_ROOT)); + Flux output = + this.decoder.decode(source, ResolvableType.forClass(Pojo.class), null); + + TestSubscriber + .subscribe(output) + .assertNoError() + .assertComplete() + .assertValues(new Pojo("foofoo", "barbar")); + } + + @Test + public void decodeSingleXmlTypeElement() throws Exception { + Flux source = Flux.just(stringBuffer(POJO_ROOT)); + Flux output = this.decoder + .decode(source, ResolvableType.forClass(TypePojo.class), null); + + TestSubscriber + .subscribe(output) + .assertNoError() + .assertComplete() + .assertValues(new TypePojo("foofoo", "barbar")); + } + + @Test + public void decodeMultipleXmlRootElement() throws Exception { + Flux source = Flux.just(stringBuffer(POJO_CHILD)); + Flux output = + this.decoder.decode(source, ResolvableType.forClass(Pojo.class), null); + + TestSubscriber + .subscribe(output) + .assertNoError() + .assertComplete() + .assertValues(new Pojo("foo", "bar"), new Pojo("foofoo", "barbar")); + } + + @Test + public void decodeMultipleXmlTypeElement() throws Exception { + Flux source = Flux.just(stringBuffer(POJO_CHILD)); + Flux output = this.decoder + .decode(source, ResolvableType.forClass(TypePojo.class), null); + + TestSubscriber + .subscribe(output) + .assertNoError() + .assertComplete() + .assertValues(new TypePojo("foo", "bar"), new TypePojo("foofoo", "barbar")); + } + + @Test + public void toExpectedQName() { + assertEquals(new QName("pojo"), this.decoder.toQName(Pojo.class)); + assertEquals(new QName("pojo"), this.decoder.toQName(TypePojo.class)); + + assertEquals(new QName("namespace", "name"), + this.decoder.toQName(XmlRootElementWithNameAndNamespace.class)); + assertEquals(new QName("namespace", "name"), + this.decoder.toQName(XmlRootElementWithName.class)); + assertEquals(new QName("namespace", "xmlRootElement"), + this.decoder.toQName(XmlRootElement.class)); + + assertEquals(new QName("namespace", "name"), + this.decoder.toQName(XmlTypeWithNameAndNamespace.class)); + assertEquals(new QName("namespace", "name"), + this.decoder.toQName(XmlTypeWithName.class)); + assertEquals(new QName("namespace", "xmlType"), + this.decoder.toQName(XmlType.class)); + + } + + @javax.xml.bind.annotation.XmlType(name = "pojo") + public static class TypePojo { + + private String foo; + + private String bar; + + public TypePojo() { + } + + public TypePojo(String foo, String bar) { + this.foo = foo; + this.bar = bar; + } + + public String getFoo() { + return this.foo; + } + + public void setFoo(String foo) { + this.foo = foo; + } + + public String getBar() { + return this.bar; + } + + public void setBar(String bar) { + this.bar = bar; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o instanceof TypePojo) { + TypePojo other = (TypePojo) o; + return this.foo.equals(other.foo) && this.bar.equals(other.bar); + } + return false; + } + + } +} diff --git a/spring-web-reactive/src/test/java/org/springframework/http/codec/xml/Jaxb2EncoderTests.java b/spring-web-reactive/src/test/java/org/springframework/http/codec/xml/Jaxb2EncoderTests.java new file mode 100644 index 0000000000..4c51b88268 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/http/codec/xml/Jaxb2EncoderTests.java @@ -0,0 +1,94 @@ +/* + * Copyright 2002-2016 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.codec.xml; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.junit.Before; +import org.junit.Test; +import org.xml.sax.SAXException; +import reactor.core.publisher.Flux; +import reactor.core.test.TestSubscriber; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.AbstractDataBufferAllocatingTestCase; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.support.DataBufferTestUtils; +import org.springframework.core.io.buffer.support.DataBufferUtils; +import org.springframework.http.MediaType; +import org.springframework.http.codec.Pojo; + +import static org.custommonkey.xmlunit.XMLAssert.assertXMLEqual; +import static org.custommonkey.xmlunit.XMLAssert.fail; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * @author Sebastien Deleuze + * @author Arjen Poutsma + */ +public class Jaxb2EncoderTests extends AbstractDataBufferAllocatingTestCase { + + private Jaxb2Encoder encoder; + + @Before + public void createEncoder() { + this.encoder = new Jaxb2Encoder(); + } + + @Test + public void canEncode() { + assertTrue(this.encoder.canEncode(ResolvableType.forClass(Pojo.class), + MediaType.APPLICATION_XML)); + assertTrue(this.encoder.canEncode(ResolvableType.forClass(Pojo.class), + MediaType.TEXT_XML)); + assertFalse(this.encoder.canEncode(ResolvableType.forClass(Pojo.class), + MediaType.APPLICATION_JSON)); + + assertTrue(this.encoder.canEncode( + ResolvableType.forClass(Jaxb2DecoderTests.TypePojo.class), + MediaType.APPLICATION_XML)); + + assertFalse(this.encoder.canEncode(ResolvableType.forClass(getClass()), + MediaType.APPLICATION_XML)); + } + + @Test + public void encode() { + Flux source = Flux.just(new Pojo("foofoo", "barbar"), new Pojo("foofoofoo", "barbarbar")); + Flux output = this.encoder.encode(source, this.bufferFactory, + ResolvableType.forClass(Pojo.class), + MediaType.APPLICATION_XML); + TestSubscriber + .subscribe(output) + .assertValuesWith(dataBuffer -> { + try { + String s = DataBufferTestUtils + .dumpString(dataBuffer, StandardCharsets.UTF_8); + assertXMLEqual("barbarfoofoo", s); + } + catch (SAXException | IOException e) { + fail(e.getMessage()); + } + finally { + DataBufferUtils.release(dataBuffer); + } + }); + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/http/codec/xml/XmlEventDecoderTests.java b/spring-web-reactive/src/test/java/org/springframework/http/codec/xml/XmlEventDecoderTests.java new file mode 100644 index 0000000000..09a82eb9ac --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/http/codec/xml/XmlEventDecoderTests.java @@ -0,0 +1,101 @@ +/* + * Copyright 2002-2016 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.codec.xml; + +import javax.xml.stream.events.XMLEvent; + +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.test.TestSubscriber; + +import org.springframework.core.io.buffer.AbstractDataBufferAllocatingTestCase; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * @author Arjen Poutsma + */ +public class XmlEventDecoderTests extends AbstractDataBufferAllocatingTestCase { + + private static final String XML = "" + + "" + + "foofoo" + + "barbar" + + ""; + + private XmlEventDecoder decoder = new XmlEventDecoder(); + + @Test + public void toXMLEventsAalto() { + + Flux events = + this.decoder.decode(Flux.just(stringBuffer(XML)), null, null); + + TestSubscriber + .subscribe(events) + .assertNoError() + .assertComplete() + .assertValuesWith(e -> assertTrue(e.isStartDocument()), + e -> assertStartElement(e, "pojo"), + e -> assertStartElement(e, "foo"), + e -> assertCharacters(e, "foofoo"), + e -> assertEndElement(e, "foo"), + e -> assertStartElement(e, "bar"), + e -> assertCharacters(e, "barbar"), + e -> assertEndElement(e, "bar"), + e -> assertEndElement(e, "pojo")); + } + + @Test + public void toXMLEventsNonAalto() { + decoder.useAalto = false; + + Flux events = + this.decoder.decode(Flux.just(stringBuffer(XML)), null, null); + + TestSubscriber + .subscribe(events) + .assertNoError() + .assertComplete() + .assertValuesWith(e -> assertTrue(e.isStartDocument()), + e -> assertStartElement(e, "pojo"), + e -> assertStartElement(e, "foo"), + e -> assertCharacters(e, "foofoo"), + e -> assertEndElement(e, "foo"), + e -> assertStartElement(e, "bar"), + e -> assertCharacters(e, "barbar"), + e -> assertEndElement(e, "bar"), e -> assertEndElement(e, "pojo"), + e -> assertTrue(e.isEndDocument())); + } + + private static void assertStartElement(XMLEvent event, String expectedLocalName) { + assertTrue(event.isStartElement()); + assertEquals(expectedLocalName, event.asStartElement().getName().getLocalPart()); + } + + private static void assertEndElement(XMLEvent event, String expectedLocalName) { + assertTrue(event + " is no end element", event.isEndElement()); + assertEquals(expectedLocalName, event.asEndElement().getName().getLocalPart()); + } + + private static void assertCharacters(XMLEvent event, String expectedData) { + assertTrue(event.isCharacters()); + assertEquals(expectedData, event.asCharacters().getData()); + } + +} \ No newline at end of file diff --git a/spring-web-reactive/src/test/java/org/springframework/http/codec/xml/jaxb/XmlRootElement.java b/spring-web-reactive/src/test/java/org/springframework/http/codec/xml/jaxb/XmlRootElement.java new file mode 100644 index 0000000000..746d218c77 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/http/codec/xml/jaxb/XmlRootElement.java @@ -0,0 +1,25 @@ +/* + * Copyright 2002-2016 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.codec.xml.jaxb; + +/** + * @author Arjen Poutsma + */ +@javax.xml.bind.annotation.XmlRootElement +public class XmlRootElement { + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/http/codec/xml/jaxb/XmlRootElementWithName.java b/spring-web-reactive/src/test/java/org/springframework/http/codec/xml/jaxb/XmlRootElementWithName.java new file mode 100644 index 0000000000..6509be8997 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/http/codec/xml/jaxb/XmlRootElementWithName.java @@ -0,0 +1,27 @@ +/* + * Copyright 2002-2016 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.codec.xml.jaxb; + +import javax.xml.bind.annotation.XmlRootElement; + +/** + * @author Arjen Poutsma + */ +@XmlRootElement(name = "name") +public class XmlRootElementWithName { + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/http/codec/xml/jaxb/XmlRootElementWithNameAndNamespace.java b/spring-web-reactive/src/test/java/org/springframework/http/codec/xml/jaxb/XmlRootElementWithNameAndNamespace.java new file mode 100644 index 0000000000..0a702e2f01 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/http/codec/xml/jaxb/XmlRootElementWithNameAndNamespace.java @@ -0,0 +1,27 @@ +/* + * Copyright 2002-2016 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.codec.xml.jaxb; + +import javax.xml.bind.annotation.XmlRootElement; + +/** + * @author Arjen Poutsma + */ +@XmlRootElement(name = "name", namespace = "namespace") +public class XmlRootElementWithNameAndNamespace { + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/http/codec/xml/jaxb/XmlType.java b/spring-web-reactive/src/test/java/org/springframework/http/codec/xml/jaxb/XmlType.java new file mode 100644 index 0000000000..747d99e5c1 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/http/codec/xml/jaxb/XmlType.java @@ -0,0 +1,25 @@ +/* + * Copyright 2002-2016 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.codec.xml.jaxb; + +/** + * @author Arjen Poutsma + */ +@javax.xml.bind.annotation.XmlType +public class XmlType { + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/http/codec/xml/jaxb/XmlTypeWithName.java b/spring-web-reactive/src/test/java/org/springframework/http/codec/xml/jaxb/XmlTypeWithName.java new file mode 100644 index 0000000000..11b5f251f1 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/http/codec/xml/jaxb/XmlTypeWithName.java @@ -0,0 +1,27 @@ +/* + * Copyright 2002-2016 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.codec.xml.jaxb; + +import javax.xml.bind.annotation.XmlType; + +/** + * @author Arjen Poutsma + */ +@XmlType(name = "name") +public class XmlTypeWithName { + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/http/codec/xml/jaxb/XmlTypeWithNameAndNamespace.java b/spring-web-reactive/src/test/java/org/springframework/http/codec/xml/jaxb/XmlTypeWithNameAndNamespace.java new file mode 100644 index 0000000000..3396397d66 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/http/codec/xml/jaxb/XmlTypeWithNameAndNamespace.java @@ -0,0 +1,27 @@ +/* + * Copyright 2002-2016 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.codec.xml.jaxb; + +import javax.xml.bind.annotation.XmlType; + +/** + * @author Arjen Poutsma + */ +@XmlType(name = "name", namespace = "namespace") +public class XmlTypeWithNameAndNamespace { + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/http/codec/xml/jaxb/package-info.java b/spring-web-reactive/src/test/java/org/springframework/http/codec/xml/jaxb/package-info.java new file mode 100644 index 0000000000..f3b3a59496 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/http/codec/xml/jaxb/package-info.java @@ -0,0 +1,2 @@ +@javax.xml.bind.annotation.XmlSchema(namespace = "namespace") +package org.springframework.http.codec.xml.jaxb; diff --git a/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/AbstractHttpHandlerIntegrationTests.java b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/AbstractHttpHandlerIntegrationTests.java new file mode 100644 index 0000000000..1b705ed5f5 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/AbstractHttpHandlerIntegrationTests.java @@ -0,0 +1,73 @@ +/* + * Copyright 2002-2015 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.reactive; + +import java.io.File; + +import org.junit.After; +import org.junit.Before; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import org.springframework.http.server.reactive.bootstrap.HttpServer; +import org.springframework.http.server.reactive.bootstrap.JettyHttpServer; +import org.springframework.http.server.reactive.bootstrap.ReactorHttpServer; +import org.springframework.http.server.reactive.bootstrap.RxNettyHttpServer; +import org.springframework.http.server.reactive.bootstrap.TomcatHttpServer; +import org.springframework.http.server.reactive.bootstrap.UndertowHttpServer; +import org.springframework.util.SocketUtils; + + +@RunWith(Parameterized.class) +public abstract class AbstractHttpHandlerIntegrationTests { + + protected int port; + + @Parameterized.Parameter(0) + public HttpServer server; + + + @Parameterized.Parameters(name = "server [{0}]") + public static Object[][] arguments() { + File base = new File(System.getProperty("java.io.tmpdir")); + return new Object[][] { + {new JettyHttpServer()}, + {new RxNettyHttpServer()}, + {new ReactorHttpServer()}, + {new TomcatHttpServer(base.getAbsolutePath())}, + {new UndertowHttpServer()} + }; + } + + + @Before + public void setup() throws Exception { + this.port = SocketUtils.findAvailableTcpPort(); + this.server.setPort(this.port); + this.server.setHandler(createHttpHandler()); + this.server.afterPropertiesSet(); + this.server.start(); + } + + protected abstract HttpHandler createHttpHandler(); + + @After + public void tearDown() throws Exception { + this.server.stop(); + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/AsyncIntegrationTests.java b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/AsyncIntegrationTests.java new file mode 100644 index 0000000000..ecfaacb4b2 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/AsyncIntegrationTests.java @@ -0,0 +1,74 @@ +/* + * Copyright 2002-2016 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.reactive; + +import java.net.URI; +import java.time.Duration; + +import org.hamcrest.Matchers; +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Scheduler; +import reactor.core.scheduler.Schedulers; + +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestTemplate; + +import static org.junit.Assert.assertThat; + +/** + * Temporarily does not extend AbstractHttpHandlerIntegrationTests. + * + * @author Stephane Maldini + */ +public class AsyncIntegrationTests extends AbstractHttpHandlerIntegrationTests { + + private final Scheduler asyncGroup = Schedulers.parallel(); + + private final DataBufferFactory dataBufferFactory = new DefaultDataBufferFactory(); + + @Override + protected AsyncHandler createHttpHandler() { + return new AsyncHandler(); + } + + @SuppressWarnings("unchecked") + @Test + public void basicTest() throws Exception { + URI url = new URI("http://localhost:" + port); + ResponseEntity response = new RestTemplate().exchange(RequestEntity.get(url) + .build(), String.class); + + assertThat(response.getBody(), Matchers.equalTo("hello")); + } + + private class AsyncHandler implements HttpHandler { + + @Override + public Mono handle(ServerHttpRequest request, ServerHttpResponse response) { + return response.writeWith(Flux.just("h", "e", "l", "l", "o") + .delay(Duration.ofMillis(100)) + .publishOn(asyncGroup) + .collect(dataBufferFactory::allocateBuffer, (buffer, str) -> buffer.write(str.getBytes()))); + } + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/ChannelSendOperatorTests.java b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/ChannelSendOperatorTests.java new file mode 100644 index 0000000000..730e88f088 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/ChannelSendOperatorTests.java @@ -0,0 +1,190 @@ +/* + * Copyright 2002-2015 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.reactive; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import org.junit.Before; +import org.junit.Test; +import org.reactivestreams.Publisher; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Signal; +import reactor.core.subscriber.SubscriberBarrier; + +import static org.junit.Assert.*; + +/** + * @author Rossen Stoyanchev + * @author Stephane Maldini + */ +@SuppressWarnings("ThrowableResultOfMethodCallIgnored") +public class ChannelSendOperatorTests { + + private OneByOneAsyncWriter writer; + + + @Before + public void setUp() throws Exception { + this.writer = new OneByOneAsyncWriter(); + } + + private Mono sendOperator(Publisher source){ + return new ChannelSendOperator<>(source, writer::send); + } + + @Test + public void errorBeforeFirstItem() throws Exception { + IllegalStateException error = new IllegalStateException("boo"); + Mono completion = Mono.error(error).as(this::sendOperator); + Signal signal = completion.materialize().block(); + + assertNotNull(signal); + assertSame("Unexpected signal: " + signal, error, signal.getThrowable()); + } + + @Test + public void completionBeforeFirstItem() throws Exception { + Mono completion = Flux.empty().as(this::sendOperator); + Signal signal = completion.materialize().block(); + + assertNotNull(signal); + assertTrue("Unexpected signal: " + signal, signal.isOnComplete()); + + assertEquals(0, this.writer.items.size()); + assertTrue(this.writer.completed); + } + + @Test + public void writeOneItem() throws Exception { + Mono completion = Flux.just("one").as(this::sendOperator); + Signal signal = completion.materialize().block(); + + assertNotNull(signal); + assertTrue("Unexpected signal: " + signal, signal.isOnComplete()); + + assertEquals(1, this.writer.items.size()); + assertEquals("one", this.writer.items.get(0)); + assertTrue(this.writer.completed); + } + + + @Test + public void writeMultipleItems() throws Exception { + List items = Arrays.asList("one", "two", "three"); + Mono completion = Flux.fromIterable(items).as(this::sendOperator); + Signal signal = completion.materialize().block(); + + assertNotNull(signal); + assertTrue("Unexpected signal: " + signal, signal.isOnComplete()); + + assertEquals(3, this.writer.items.size()); + assertEquals("one", this.writer.items.get(0)); + assertEquals("two", this.writer.items.get(1)); + assertEquals("three", this.writer.items.get(2)); + assertTrue(this.writer.completed); + } + + @Test + public void errorAfterMultipleItems() throws Exception { + IllegalStateException error = new IllegalStateException("boo"); + Flux publisher = Flux.generate(() -> 0, (idx , subscriber) -> { + int i = ++idx; + subscriber.next(String.valueOf(i)); + if (i == 3) { + subscriber.fail(error); + } + return i; + }); + Mono completion = publisher.as(this::sendOperator); + Signal signal = completion.materialize().block(); + + assertNotNull(signal); + assertSame("Unexpected signal: " + signal, error, signal.getThrowable()); + + assertEquals(3, this.writer.items.size()); + assertEquals("1", this.writer.items.get(0)); + assertEquals("2", this.writer.items.get(1)); + assertEquals("3", this.writer.items.get(2)); + assertSame(error, this.writer.error); + } + + + private static class OneByOneAsyncWriter { + + private List items = new ArrayList<>(); + + private boolean completed = false; + + private Throwable error; + + + public Publisher send(Publisher publisher) { + return subscriber -> { + Executors.newSingleThreadScheduledExecutor().schedule(() -> publisher.subscribe(new WriteSubscriber(subscriber)), + 50, TimeUnit.MILLISECONDS); + }; + } + + private class WriteSubscriber extends SubscriberBarrier { + + public WriteSubscriber(Subscriber subscriber) { + super(subscriber); + } + + @Override + protected void doOnSubscribe(Subscription subscription) { + subscription.request(1); + } + + @Override + public void doNext(String item) { + items.add(item); + this.subscription.request(1); + } + + @Override + public void doError(Throwable ex) { + error = ex; + this.subscriber.onError(ex); + } + + @Override + public void doComplete() { + completed = true; + this.subscriber.onComplete(); + } + } + } + + private final static Subscription NO_OP_SUBSCRIPTION = new Subscription() { + + @Override + public void request(long n) { + } + + @Override + public void cancel() { + } + }; + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/CookieIntegrationTests.java b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/CookieIntegrationTests.java new file mode 100644 index 0000000000..d39d60a45c --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/CookieIntegrationTests.java @@ -0,0 +1,114 @@ +/* + * Copyright 2002-2015 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.reactive; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import reactor.core.publisher.Mono; + +import org.springframework.http.HttpCookie; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.http.ResponseCookie; +import org.springframework.web.client.RestTemplate; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.equalToIgnoringCase; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThat; + +/** + * @author Rossen Stoyanchev + */ +@RunWith(Parameterized.class) +public class CookieIntegrationTests extends AbstractHttpHandlerIntegrationTests { + + private CookieHandler cookieHandler; + + @Override + protected HttpHandler createHttpHandler() { + this.cookieHandler = new CookieHandler(); + return this.cookieHandler; + } + + + @SuppressWarnings("unchecked") + @Test + public void basicTest() throws Exception { + URI url = new URI("http://localhost:" + port); + String header = "SID=31d4d96e407aad42; lang=en-US"; + ResponseEntity response = new RestTemplate().exchange( + RequestEntity.get(url).header("Cookie", header).build(), Void.class); + + Map> requestCookies = this.cookieHandler.requestCookies; + assertEquals(2, requestCookies.size()); + + List list = requestCookies.get("SID"); + assertEquals(1, list.size()); + assertEquals("31d4d96e407aad42", list.iterator().next().getValue()); + + list = requestCookies.get("lang"); + assertEquals(1, list.size()); + assertEquals("en-US", list.iterator().next().getValue()); + + List headerValues = response.getHeaders().get("Set-Cookie"); + assertEquals(2, headerValues.size()); + + assertThat(splitCookie(headerValues.get(0)), containsInAnyOrder(equalTo("SID=31d4d96e407aad42"), + equalToIgnoringCase("Path=/"), equalToIgnoringCase("Secure"), equalToIgnoringCase("HttpOnly"))); + + assertThat(splitCookie(headerValues.get(1)), containsInAnyOrder(equalTo("lang=en-US"), + equalToIgnoringCase("Path=/"), equalToIgnoringCase("Domain=example.com"))); + } + + // No client side HttpCookie support yet + private List splitCookie(String value) { + List list = new ArrayList<>(); + for (String s : value.split(";")){ + list.add(s.trim()); + } + return list; + } + + + private class CookieHandler implements HttpHandler { + + private Map> requestCookies; + + + @Override + public Mono handle(ServerHttpRequest request, ServerHttpResponse response) { + + this.requestCookies = request.getCookies(); + this.requestCookies.size(); // Cause lazy loading + + response.getCookies().add("SID", ResponseCookie.from("SID", "31d4d96e407aad42") + .path("/").secure(true).httpOnly(true).build()); + response.getCookies().add("lang", ResponseCookie.from("lang", "en-US") + .domain("example.com").path("/").build()); + + return response.setComplete(); + } + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/EchoHandlerIntegrationTests.java b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/EchoHandlerIntegrationTests.java new file mode 100644 index 0000000000..dd0a4a5ea1 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/EchoHandlerIntegrationTests.java @@ -0,0 +1,73 @@ +/* + * Copyright 2002-2016 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.reactive; + +import java.net.URI; +import java.util.Random; + +import org.junit.Test; +import reactor.core.publisher.Mono; + +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestTemplate; + +import static org.junit.Assert.assertArrayEquals; + + +public class EchoHandlerIntegrationTests extends AbstractHttpHandlerIntegrationTests { + + private static final int REQUEST_SIZE = 4096 * 3; + + private Random rnd = new Random(); + + + @Override + protected EchoHandler createHttpHandler() { + return new EchoHandler(); + } + + + @Test + public void echo() throws Exception { + RestTemplate restTemplate = new RestTemplate(); + + byte[] body = randomBytes(); + RequestEntity request = RequestEntity.post(new URI("http://localhost:" + port)).body(body); + ResponseEntity response = restTemplate.exchange(request, byte[].class); + + assertArrayEquals(body, response.getBody()); + } + + + private byte[] randomBytes() { + byte[] buffer = new byte[REQUEST_SIZE]; + rnd.nextBytes(buffer); + return buffer; + } + + /** + * @author Arjen Poutsma + */ + public static class EchoHandler implements HttpHandler { + + @Override + public Mono handle(ServerHttpRequest request, ServerHttpResponse response) { + return response.writeWith(request.getBody()); + } + } +} diff --git a/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/ErrorHandlerIntegrationTests.java b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/ErrorHandlerIntegrationTests.java new file mode 100644 index 0000000000..8f8b1d6320 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/ErrorHandlerIntegrationTests.java @@ -0,0 +1,108 @@ +/* + * Copyright 2002-2016 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.reactive; + +import java.io.IOException; +import java.net.URI; + +import org.junit.Test; +import reactor.core.publisher.Mono; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.http.server.reactive.bootstrap.ReactorHttpServer; +import org.springframework.web.client.ResponseErrorHandler; +import org.springframework.web.client.RestTemplate; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assume.assumeFalse; + +/** + * @author Arjen Poutsma + */ +public class ErrorHandlerIntegrationTests extends AbstractHttpHandlerIntegrationTests { + + private ErrorHandler handler = new ErrorHandler(); + + @Override + protected HttpHandler createHttpHandler() { + return handler; + } + + @Test + public void response() throws Exception { + // TODO: fix Reactor + assumeFalse(server instanceof ReactorHttpServer); + + RestTemplate restTemplate = new RestTemplate(); + restTemplate.setErrorHandler(NO_OP_ERROR_HANDLER); + + ResponseEntity response = restTemplate + .getForEntity(new URI("http://localhost:" + port + "/response"), + String.class); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + } + + @Test + public void returnValue() throws Exception { + // TODO: fix Reactor + assumeFalse(server instanceof ReactorHttpServer); + + RestTemplate restTemplate = new RestTemplate(); + restTemplate.setErrorHandler(NO_OP_ERROR_HANDLER); + + ResponseEntity response = restTemplate + .getForEntity(new URI("http://localhost:" + port + "/returnValue"), + String.class); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + } + + private static class ErrorHandler implements HttpHandler { + + @Override + public Mono handle(ServerHttpRequest request, ServerHttpResponse response) { + Exception error = new UnsupportedOperationException(); + String path = request.getURI().getPath(); + if (path.endsWith("response")) { + return response.writeWith(Mono.error(error)); + } + else if (path.endsWith("returnValue")) { + return Mono.error(error); + } + else { + return Mono.empty(); + } + } + } + + private static final ResponseErrorHandler NO_OP_ERROR_HANDLER = + new ResponseErrorHandler() { + + @Override + public boolean hasError(ClientHttpResponse response) throws IOException { + return false; + } + + @Override + public void handleError(ClientHttpResponse response) throws IOException { + } + }; + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/FlushingIntegrationTests.java b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/FlushingIntegrationTests.java new file mode 100644 index 0000000000..16b608e60e --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/FlushingIntegrationTests.java @@ -0,0 +1,88 @@ +/* + * Copyright 2002-2016 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.reactive; + +import org.junit.Before; +import org.junit.Test; + +import static org.springframework.web.client.reactive.ClientWebRequestBuilders.get; +import static org.springframework.web.client.reactive.ResponseExtractors.bodyStream; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.test.TestSubscriber; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.FlushingDataBuffer; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.client.reactive.WebClient; + +/** + * @author Sebastien Deleuze + */ +public class FlushingIntegrationTests extends AbstractHttpHandlerIntegrationTests { + + private WebClient webClient; + + @Before + public void setup() throws Exception { + super.setup(); + this.webClient = new WebClient(new ReactorClientHttpConnector()); + } + + @Test + public void testFlushing() throws Exception { + Mono result = this.webClient + .perform(get("http://localhost:" + port)) + .extract(bodyStream(String.class)) + .takeUntil(s -> { + return s.endsWith("data1"); + }) + .reduce((s1, s2) -> s1 + s2); + + TestSubscriber + .subscribe(result) + .await() + .assertValues("data0data1"); + } + + + @Override + protected HttpHandler createHttpHandler() { + return new FlushingHandler(); + } + + // Handler that never completes designed to test if flushing is perform correctly when + // a FlushingDataBuffer is written + private static class FlushingHandler implements HttpHandler { + + @Override + public Mono handle(ServerHttpRequest request, ServerHttpResponse response) { + Flux responseBody = Flux + .intervalMillis(50) + .map(l -> { + byte[] data = ("data" + l).getBytes(); + DataBuffer buffer = response.bufferFactory().allocateBuffer(data.length); + buffer.write(data); + return buffer; + }) + .take(2) + .concatWith(Mono.just(FlushingDataBuffer.INSTANCE)) + .concatWith(Flux.never()); + return response.writeWith(responseBody); + } + } +} diff --git a/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/MockServerHttpRequest.java b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/MockServerHttpRequest.java new file mode 100644 index 0000000000..9448f2e9e2 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/MockServerHttpRequest.java @@ -0,0 +1,105 @@ +/* + * Copyright 2002-2016 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.reactive; + +import java.net.URI; + +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.HttpCookie; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +/** + * Mock implementation of {@link ServerHttpRequest}. + * @author Rossen Stoyanchev + */ +public class MockServerHttpRequest implements ServerHttpRequest { + + private HttpMethod httpMethod; + + private URI uri; + + private MultiValueMap queryParams = new LinkedMultiValueMap<>(); + + private HttpHeaders headers = new HttpHeaders(); + + private MultiValueMap cookies = new LinkedMultiValueMap<>(); + + private Flux body; + + + public MockServerHttpRequest(HttpMethod httpMethod, URI uri) { + this.httpMethod = httpMethod; + this.uri = uri; + } + + public MockServerHttpRequest(Publisher body, HttpMethod httpMethod, + URI uri) { + this.body = Flux.from(body); + this.httpMethod = httpMethod; + this.uri = uri; + } + + + @Override + public HttpMethod getMethod() { + return this.httpMethod; + } + + public void setHttpMethod(HttpMethod httpMethod) { + this.httpMethod = httpMethod; + } + + @Override + public URI getURI() { + return this.uri; + } + + public void setUri(URI uri) { + this.uri = uri; + } + + @Override + public HttpHeaders getHeaders() { + return this.headers; + } + + @Override + public MultiValueMap getQueryParams() { + return this.queryParams; + } + + @Override + public MultiValueMap getCookies() { + return this.cookies; + } + + @Override + public Flux getBody() { + return this.body; + } + + public Mono writeWith(Publisher body) { + this.body = Flux.from(body); + return this.body.then(); + } +} diff --git a/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/MockServerHttpResponse.java b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/MockServerHttpResponse.java new file mode 100644 index 0000000000..3ffafd090c --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/MockServerHttpResponse.java @@ -0,0 +1,95 @@ +/* + * Copyright 2002-2016 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.reactive; + +import java.util.function.Supplier; + +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +/** + * Mock implementation of {@link ServerHttpResponse}. + * @author Rossen Stoyanchev + */ +public class MockServerHttpResponse implements ServerHttpResponse { + + private HttpStatus status; + + private HttpHeaders headers = new HttpHeaders(); + + private MultiValueMap cookies = new LinkedMultiValueMap<>(); + + private Publisher body; + + private DataBufferFactory dataBufferFactory = new DefaultDataBufferFactory(); + + + @Override + public boolean setStatusCode(HttpStatus status) { + this.status = status; + return true; + } + + public HttpStatus getStatusCode() { + return this.status; + } + + @Override + public HttpHeaders getHeaders() { + return this.headers; + } + + @Override + public MultiValueMap getCookies() { + return this.cookies; + } + + public Publisher getBody() { + return this.body; + } + + @Override + public Mono writeWith(Publisher body) { + this.body = body; + return Flux.from(this.body).then(); + } + + @Override + public void beforeCommit(Supplier> action) { + } + + @Override + public Mono setComplete() { + return Mono.empty(); + } + + @Override + public DataBufferFactory bufferFactory() { + return this.dataBufferFactory; + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/RandomHandlerIntegrationTests.java b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/RandomHandlerIntegrationTests.java new file mode 100644 index 0000000000..18062e4360 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/RandomHandlerIntegrationTests.java @@ -0,0 +1,117 @@ +/* + * Copyright 2002-2016 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.reactive; + +import java.net.URI; +import java.util.Random; + +import org.junit.Test; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestTemplate; + +import static org.junit.Assert.*; + +public class RandomHandlerIntegrationTests extends AbstractHttpHandlerIntegrationTests { + + public static final int REQUEST_SIZE = 4096 * 3; + + public static final int RESPONSE_SIZE = 1024 * 4; + + private final Random rnd = new Random(); + + private final RandomHandler handler = new RandomHandler(); + + private final DataBufferFactory dataBufferFactory = new DefaultDataBufferFactory(); + + + @Override + protected RandomHandler createHttpHandler() { + return handler; + } + + + @Test + public void random() throws Throwable { + // TODO: fix Reactor support + + RestTemplate restTemplate = new RestTemplate(); + + byte[] body = randomBytes(); + RequestEntity request = RequestEntity.post(new URI("http://localhost:" + port)).body(body); + ResponseEntity response = restTemplate.exchange(request, byte[].class); + + assertNotNull(response.getBody()); + assertEquals(RESPONSE_SIZE, + response.getHeaders().getContentLength()); + assertEquals(RESPONSE_SIZE, response.getBody().length); + } + + + private byte[] randomBytes() { + byte[] buffer = new byte[REQUEST_SIZE]; + rnd.nextBytes(buffer); + return buffer; + } + + private class RandomHandler implements HttpHandler { + + public static final int CHUNKS = 16; + + @Override + public Mono handle(ServerHttpRequest request, ServerHttpResponse response) { + Mono requestSizeMono = request.getBody(). + reduce(0, (integer, dataBuffer) -> integer + + dataBuffer.readableByteCount()). + doAfterTerminate((size, throwable) -> { + assertNull(throwable); + assertEquals(REQUEST_SIZE, (long) size); + }); + + + + response.getHeaders().setContentLength(RESPONSE_SIZE); + + return requestSizeMono.then(response.writeWith(multipleChunks())); + } + + private Publisher singleChunk() { + return Mono.just(randomBuffer(RESPONSE_SIZE)); + } + + private Publisher multipleChunks() { + int chunkSize = RESPONSE_SIZE / CHUNKS; + return Flux.range(1, CHUNKS).map(integer -> randomBuffer(chunkSize)); + } + + private DataBuffer randomBuffer(int size) { + byte[] bytes = new byte[size]; + rnd.nextBytes(bytes); + DataBuffer buffer = dataBufferFactory.allocateBuffer(size); + buffer.write(bytes); + return buffer; + } + + } +} diff --git a/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/ServerHttpRequestTests.java b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/ServerHttpRequestTests.java new file mode 100644 index 0000000000..b6743cd77e --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/ServerHttpRequestTests.java @@ -0,0 +1,80 @@ +/* + * Copyright 2002-2016 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.reactive; + +import java.util.Arrays; +import java.util.Collections; +import javax.servlet.http.HttpServletRequest; + +import org.junit.Test; + +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.util.MultiValueMap; + +import static org.junit.Assert.assertEquals; + +/** + * Unit tests for {@link AbstractServerHttpRequest}. + * + * @author Rossen Stoyanchev + */ +public class ServerHttpRequestTests { + + + @Test + public void queryParamsNone() throws Exception { + MultiValueMap params = createHttpRequest("/path").getQueryParams(); + assertEquals(0, params.size()); + } + + @Test + public void queryParams() throws Exception { + MultiValueMap params = createHttpRequest("/path?a=A&b=B").getQueryParams(); + assertEquals(2, params.size()); + assertEquals(Collections.singletonList("A"), params.get("a")); + assertEquals(Collections.singletonList("B"), params.get("b")); + } + + @Test + public void queryParamsWithMulitpleValues() throws Exception { + MultiValueMap params = createHttpRequest("/path?a=1&a=2").getQueryParams(); + assertEquals(1, params.size()); + assertEquals(Arrays.asList("1", "2"), params.get("a")); + } + + @Test + public void queryParamsWithEmptyValue() throws Exception { + MultiValueMap params = createHttpRequest("/path?a=").getQueryParams(); + assertEquals(1, params.size()); + assertEquals(Collections.singletonList(""), params.get("a")); + } + + @Test + public void queryParamsWithNoValue() throws Exception { + MultiValueMap params = createHttpRequest("/path?a").getQueryParams(); + assertEquals(1, params.size()); + assertEquals(Collections.singletonList(null), params.get("a")); + } + + private ServerHttpRequest createHttpRequest(String path) throws Exception { + HttpServletRequest servletRequest = new MockHttpServletRequest("GET", path); + return new ServletServerHttpRequest(servletRequest, + new DefaultDataBufferFactory(), 1024); + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/ServerHttpResponseTests.java b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/ServerHttpResponseTests.java new file mode 100644 index 0000000000..a5e5637605 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/ServerHttpResponseTests.java @@ -0,0 +1,187 @@ +/* + * Copyright 2002-2016 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.reactive; + +import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.List; + +import org.junit.Test; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.http.ResponseCookie; + +import static junit.framework.TestCase.assertTrue; +import static org.junit.Assert.*; + +/** + * @author Rossen Stoyanchev + * @author Sebastien Deleuze + */ +public class ServerHttpResponseTests { + + public static final Charset UTF_8 = Charset.forName("UTF-8"); + + + @Test + public void writeWith() throws Exception { + TestServerHttpResponse response = new TestServerHttpResponse(); + response.writeWith(Flux.just(wrap("a"), wrap("b"), wrap("c"))).block(); + + assertTrue(response.statusCodeWritten); + assertTrue(response.headersWritten); + assertTrue(response.cookiesWritten); + + assertEquals(3, response.body.size()); + assertEquals("a", new String(response.body.get(0).asByteBuffer().array(), UTF_8)); + assertEquals("b", new String(response.body.get(1).asByteBuffer().array(), UTF_8)); + assertEquals("c", new String(response.body.get(2).asByteBuffer().array(), UTF_8)); + } + + @Test + public void writeWithError() throws Exception { + TestServerHttpResponse response = new TestServerHttpResponse(); + IllegalStateException error = new IllegalStateException("boo"); + response.writeWith(Flux.error(error)).otherwise(ex -> Mono.empty()).block(); + + assertFalse(response.statusCodeWritten); + assertFalse(response.headersWritten); + assertFalse(response.cookiesWritten); + assertTrue(response.body.isEmpty()); + } + + @Test + public void setComplete() throws Exception { + TestServerHttpResponse response = new TestServerHttpResponse(); + response.setComplete().block(); + + assertTrue(response.statusCodeWritten); + assertTrue(response.headersWritten); + assertTrue(response.cookiesWritten); + assertTrue(response.body.isEmpty()); + } + + @Test + public void beforeCommitWithComplete() throws Exception { + ResponseCookie cookie = ResponseCookie.from("ID", "123").build(); + TestServerHttpResponse response = new TestServerHttpResponse(); + response.beforeCommit(() -> { + response.getCookies().add(cookie.getName(), cookie); + return Mono.empty(); + }); + response.writeWith(Flux.just(wrap("a"), wrap("b"), wrap("c"))).block(); + + assertTrue(response.statusCodeWritten); + assertTrue(response.headersWritten); + assertTrue(response.cookiesWritten); + assertSame(cookie, response.getCookies().getFirst("ID")); + + assertEquals(3, response.body.size()); + assertEquals("a", new String(response.body.get(0).asByteBuffer().array(), UTF_8)); + assertEquals("b", new String(response.body.get(1).asByteBuffer().array(), UTF_8)); + assertEquals("c", new String(response.body.get(2).asByteBuffer().array(), UTF_8)); + } + + @Test + public void beforeCommitActionWithError() throws Exception { + TestServerHttpResponse response = new TestServerHttpResponse(); + IllegalStateException error = new IllegalStateException("boo"); + response.beforeCommit(() -> Mono.error(error)); + response.writeWith(Flux.just(wrap("a"), wrap("b"), wrap("c"))).block(); + + assertTrue("beforeCommit action errors should be ignored", response.statusCodeWritten); + assertTrue("beforeCommit action errors should be ignored", response.headersWritten); + assertTrue("beforeCommit action errors should be ignored", response.cookiesWritten); + assertNull(response.getCookies().get("ID")); + + assertEquals(3, response.body.size()); + assertEquals("a", new String(response.body.get(0).asByteBuffer().array(), UTF_8)); + assertEquals("b", new String(response.body.get(1).asByteBuffer().array(), UTF_8)); + assertEquals("c", new String(response.body.get(2).asByteBuffer().array(), UTF_8)); + } + + @Test + public void beforeCommitActionWithSetComplete() throws Exception { + ResponseCookie cookie = ResponseCookie.from("ID", "123").build(); + TestServerHttpResponse response = new TestServerHttpResponse(); + response.beforeCommit(() -> { + response.getCookies().add(cookie.getName(), cookie); + return Mono.empty(); + }); + response.setComplete().block(); + + assertTrue(response.statusCodeWritten); + assertTrue(response.headersWritten); + assertTrue(response.cookiesWritten); + assertTrue(response.body.isEmpty()); + assertSame(cookie, response.getCookies().getFirst("ID")); + } + + + + private DataBuffer wrap(String a) { + return new DefaultDataBufferFactory().wrap(ByteBuffer.wrap(a.getBytes(UTF_8))); + } + + + private static class TestServerHttpResponse extends AbstractServerHttpResponse { + + private boolean statusCodeWritten; + + private boolean headersWritten; + + private boolean cookiesWritten; + + private final List body = new ArrayList<>(); + + public TestServerHttpResponse() { + super(new DefaultDataBufferFactory()); + } + + @Override + public void writeStatusCode() { + assertFalse(this.statusCodeWritten); + this.statusCodeWritten = true; + } + + @Override + protected void writeHeaders() { + assertFalse(this.headersWritten); + this.headersWritten = true; + } + + @Override + protected void writeCookies() { + assertFalse(this.cookiesWritten); + this.cookiesWritten = true; + } + + @Override + protected Mono writeWithInternal(Publisher body) { + return Flux.from(body).map(b -> { + this.body.add(b); + return b; + }).then(); + } + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/ZeroCopyIntegrationTests.java b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/ZeroCopyIntegrationTests.java new file mode 100644 index 0000000000..09d5a22160 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/ZeroCopyIntegrationTests.java @@ -0,0 +1,98 @@ +/* + * Copyright 2002-2016 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.reactive; + +import java.io.File; +import java.net.URI; + +import org.junit.Test; +import reactor.core.publisher.Mono; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.http.ZeroCopyHttpOutputMessage; +import org.springframework.http.server.reactive.bootstrap.ReactorHttpServer; +import org.springframework.http.server.reactive.bootstrap.UndertowHttpServer; +import org.springframework.web.client.RestTemplate; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeTrue; + +/** + * @author Arjen Poutsma + */ +public class ZeroCopyIntegrationTests extends AbstractHttpHandlerIntegrationTests { + + private final ZeroCopyHandler handler = new ZeroCopyHandler(); + + @Override + protected HttpHandler createHttpHandler() { + return handler; + } + + @Test + public void zeroCopy() throws Exception { + // Zero-copy only does not support servlet + assumeTrue(server instanceof ReactorHttpServer || + server instanceof UndertowHttpServer); + + RestTemplate restTemplate = new RestTemplate(); + + RequestEntity request = + RequestEntity.get(new URI("http://localhost:" + port)).build(); + + ResponseEntity response = restTemplate.exchange(request, byte[].class); + + Resource logo = + new ClassPathResource("spring.png", ZeroCopyIntegrationTests.class); + + assertTrue(response.hasBody()); + assertEquals(logo.contentLength(), response.getHeaders().getContentLength()); + assertEquals(logo.contentLength(), response.getBody().length); + assertEquals(MediaType.IMAGE_PNG, response.getHeaders().getContentType()); + + } + + private static class ZeroCopyHandler implements HttpHandler { + + @Override + public Mono handle(ServerHttpRequest request, ServerHttpResponse response) { + try { + ZeroCopyHttpOutputMessage zeroCopyResponse = + (ZeroCopyHttpOutputMessage) response; + + Resource logo = new ClassPathResource("spring.png", + ZeroCopyIntegrationTests.class); + File logoFile = logo.getFile(); + zeroCopyResponse.getHeaders().setContentType(MediaType.IMAGE_PNG); + zeroCopyResponse.getHeaders().setContentLength(logoFile.length()); + return zeroCopyResponse.writeWith(logoFile, 0, logoFile.length()); + + } + catch (Throwable ex) { + return Mono.error(ex); + } + + + } + } + +} \ No newline at end of file diff --git a/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/bootstrap/HttpServer.java b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/bootstrap/HttpServer.java new file mode 100644 index 0000000000..8bc8ae12d4 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/bootstrap/HttpServer.java @@ -0,0 +1,35 @@ +/* + * Copyright 2002-2015 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.reactive.bootstrap; + + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.Lifecycle; +import org.springframework.http.server.reactive.HttpHandler; + +/** + * @author Rossen Stoyanchev + */ +public interface HttpServer extends InitializingBean, Lifecycle { + + void setHost(String host); + + void setPort(int port); + + void setHandler(HttpHandler handler); + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/bootstrap/HttpServerSupport.java b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/bootstrap/HttpServerSupport.java new file mode 100644 index 0000000000..4c22291b91 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/bootstrap/HttpServerSupport.java @@ -0,0 +1,60 @@ +/* + * Copyright 2002-2015 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.reactive.bootstrap; + +import org.springframework.http.server.reactive.HttpHandler; +import org.springframework.util.SocketUtils; + +/** + * @author Rossen Stoyanchev + */ +public class HttpServerSupport { + + private String host = "0.0.0.0"; + + private int port = -1; + + private HttpHandler httpHandler; + + public void setHost(String host) { + this.host = host; + } + + public String getHost() { + return host; + } + + public void setPort(int port) { + this.port = port; + } + + public int getPort() { + if(this.port == -1) { + this.port = SocketUtils.findAvailableTcpPort(8080); + } + return this.port; + } + + public void setHandler(HttpHandler handler) { + this.httpHandler = handler; + } + + public HttpHandler getHttpHandler() { + return this.httpHandler; + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/bootstrap/JettyHttpServer.java b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/bootstrap/JettyHttpServer.java new file mode 100644 index 0000000000..485af7594b --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/bootstrap/JettyHttpServer.java @@ -0,0 +1,89 @@ +/* + * Copyright 2002-2015 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.reactive.bootstrap; + +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.servlet.ServletHolder; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.http.server.reactive.ServletHttpHandlerAdapter; +import org.springframework.util.Assert; + +/** + * @author Rossen Stoyanchev + */ +public class JettyHttpServer extends HttpServerSupport implements InitializingBean, HttpServer { + + private Server jettyServer; + + private boolean running; + + + @Override + public boolean isRunning() { + return this.running; + } + + @Override + public void afterPropertiesSet() throws Exception { + + this.jettyServer = new Server(); + + Assert.notNull(getHttpHandler()); + ServletHttpHandlerAdapter servlet = new ServletHttpHandlerAdapter(); + servlet.setHandler(getHttpHandler()); + ServletHolder servletHolder = new ServletHolder(servlet); + + ServletContextHandler contextHandler = new ServletContextHandler(this.jettyServer, "", false, false); + contextHandler.addServlet(servletHolder, "/"); + + ServerConnector connector = new ServerConnector(this.jettyServer); + connector.setHost(getHost()); + connector.setPort(getPort()); + this.jettyServer.addConnector(connector); + } + + @Override + public void start() { + if (!this.running) { + try { + this.running = true; + this.jettyServer.start(); + } + catch (Exception ex) { + throw new IllegalStateException(ex); + } + } + } + + @Override + public void stop() { + if (this.running) { + try { + this.running = false; + jettyServer.stop(); + jettyServer.destroy(); + } + catch (Exception ex) { + throw new IllegalStateException(ex); + } + } + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/bootstrap/ReactorHttpServer.java b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/bootstrap/ReactorHttpServer.java new file mode 100644 index 0000000000..1bba2c3c85 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/bootstrap/ReactorHttpServer.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2011-2016 Pivotal Software Inc, All Rights Reserved. + * + * 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.reactive.bootstrap; + +import reactor.core.flow.Loopback; +import reactor.core.state.Completable; + +import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter; +import org.springframework.util.Assert; + +/** + * @author Stephane Maldini + */ +public class ReactorHttpServer extends HttpServerSupport + implements HttpServer, Loopback, Completable { + + private ReactorHttpHandlerAdapter reactorHandler; + + private reactor.io.netty.http.HttpServer reactorServer; + + private boolean running; + + @Override + public void afterPropertiesSet() throws Exception { + + Assert.notNull(getHttpHandler()); + this.reactorHandler = new ReactorHttpHandlerAdapter(getHttpHandler()); + this.reactorServer = reactor.io.netty.http.HttpServer.create(getHost(), getPort()); + } + + @Override + public boolean isRunning() { + return this.running; + } + + @Override + public Object connectedInput() { + return reactorServer; + } + + @Override + public Object connectedOutput() { + return reactorServer; + } + + @Override + public boolean isStarted() { + return running; + } + + @Override + public boolean isTerminated() { + return !running; + } + + @Override + public void start() { + if (!this.running) { + try { + this.reactorServer.startAndAwait(reactorHandler); + this.running = true; + } + catch (InterruptedException ex) { + throw new IllegalStateException(ex); + } + } + } + + @Override + public void stop() { + if (this.running) { + this.reactorServer.shutdown(); + this.running = false; + } + } +} diff --git a/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/bootstrap/RxNettyHttpServer.java b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/bootstrap/RxNettyHttpServer.java new file mode 100644 index 0000000000..051ba64e66 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/bootstrap/RxNettyHttpServer.java @@ -0,0 +1,70 @@ +/* + * Copyright 2002-2016 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.reactive.bootstrap; + +import java.net.InetSocketAddress; + +import io.netty.buffer.ByteBuf; + +import org.springframework.http.server.reactive.RxNettyHttpHandlerAdapter; +import org.springframework.util.Assert; + + +/** + * @author Rossen Stoyanchev + */ +public class RxNettyHttpServer extends HttpServerSupport implements HttpServer { + + private RxNettyHttpHandlerAdapter rxNettyHandler; + + private io.reactivex.netty.protocol.http.server.HttpServer rxNettyServer; + + private boolean running; + + @Override + public void afterPropertiesSet() throws Exception { + Assert.notNull(getHttpHandler()); + this.rxNettyHandler = new RxNettyHttpHandlerAdapter(getHttpHandler()); + + this.rxNettyServer = io.reactivex.netty.protocol.http.server.HttpServer + .newServer(new InetSocketAddress(getHost(), getPort())); + } + + + @Override + public boolean isRunning() { + return this.running; + } + + + @Override + public void start() { + if (!this.running) { + this.running = true; + this.rxNettyServer.start(this.rxNettyHandler); + } + } + + @Override + public void stop() { + if (this.running) { + this.running = false; + this.rxNettyServer.shutdown(); + } + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/bootstrap/TomcatHttpServer.java b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/bootstrap/TomcatHttpServer.java new file mode 100644 index 0000000000..1a47951590 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/bootstrap/TomcatHttpServer.java @@ -0,0 +1,103 @@ +/* + * Copyright 2002-2015 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.reactive.bootstrap; + +import java.io.File; + +import org.apache.catalina.Context; +import org.apache.catalina.LifecycleException; +import org.apache.catalina.startup.Tomcat; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.http.server.reactive.ServletHttpHandlerAdapter; +import org.springframework.util.Assert; + + +/** + * @author Rossen Stoyanchev + */ +public class TomcatHttpServer extends HttpServerSupport implements InitializingBean, HttpServer { + + private Tomcat tomcatServer; + + private boolean running; + + private String baseDir; + + + public TomcatHttpServer() { + } + + public TomcatHttpServer(String baseDir) { + this.baseDir = baseDir; + } + + + @Override + public boolean isRunning() { + return this.running; + } + + @Override + public void afterPropertiesSet() throws Exception { + + this.tomcatServer = new Tomcat(); + if (this.baseDir != null) { + this.tomcatServer.setBaseDir(baseDir); + } + this.tomcatServer.setHostname(getHost()); + this.tomcatServer.setPort(getPort()); + + Assert.notNull(getHttpHandler()); + ServletHttpHandlerAdapter servlet = new ServletHttpHandlerAdapter(); + servlet.setHandler(getHttpHandler()); + + File base = new File(System.getProperty("java.io.tmpdir")); + Context rootContext = tomcatServer.addContext("", base.getAbsolutePath()); + Tomcat.addServlet(rootContext, "httpHandlerServlet", servlet); + rootContext.addServletMapping("/", "httpHandlerServlet"); + } + + + @Override + public void start() { + if (!this.running) { + try { + this.running = true; + this.tomcatServer.start(); + } + catch (LifecycleException ex) { + throw new IllegalStateException(ex); + } + } + } + + @Override + public void stop() { + if (this.running) { + try { + this.running = false; + this.tomcatServer.stop(); + this.tomcatServer.destroy(); + } + catch (LifecycleException ex) { + throw new IllegalStateException(ex); + } + } + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/bootstrap/UndertowHttpServer.java b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/bootstrap/UndertowHttpServer.java new file mode 100644 index 0000000000..ca9555a816 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/bootstrap/UndertowHttpServer.java @@ -0,0 +1,73 @@ +/* + * Copyright 2002-2016 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.reactive.bootstrap; + +import io.undertow.Undertow; +import io.undertow.server.HttpHandler; + +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.http.server.reactive.UndertowHttpHandlerAdapter; +import org.springframework.util.Assert; + +/** + * @author Marek Hawrylczak + */ +public class UndertowHttpServer extends HttpServerSupport implements HttpServer { + + private Undertow server; + + private DataBufferFactory dataBufferFactory = new DefaultDataBufferFactory(); + + private boolean running; + + public void setDataBufferFactory(DataBufferFactory dataBufferFactory) { + this.dataBufferFactory = dataBufferFactory; + } + + @Override + public void afterPropertiesSet() throws Exception { + Assert.notNull(getHttpHandler()); + HttpHandler handler = + new UndertowHttpHandlerAdapter(getHttpHandler(), dataBufferFactory); + this.server = Undertow.builder().addHttpListener(getPort(), getHost()) + .setHandler(handler).build(); + } + + @Override + public void start() { + if (!this.running) { + this.server.start(); + this.running = true; + } + + } + + @Override + public void stop() { + if (this.running) { + this.server.stop(); + this.running = false; + } + } + + @Override + public boolean isRunning() { + return this.running; + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/bootstrap/package-info.java b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/bootstrap/package-info.java new file mode 100644 index 0000000000..93e5dac549 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/bootstrap/package-info.java @@ -0,0 +1,5 @@ +/** + * This package contains temporary interfaces and classes for running embedded servers. + * They are expected to be replaced by an upcoming Spring Boot support. + */ +package org.springframework.http.server.reactive.bootstrap; diff --git a/spring-web-reactive/src/test/java/org/springframework/util/xml/ListBasedXMLEventReaderTests.java b/spring-web-reactive/src/test/java/org/springframework/util/xml/ListBasedXMLEventReaderTests.java new file mode 100644 index 0000000000..30005c7f47 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/util/xml/ListBasedXMLEventReaderTests.java @@ -0,0 +1,67 @@ +/* + * Copyright 2002-2016 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.util.xml; + +import java.io.StringReader; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.List; +import javax.xml.stream.XMLEventReader; +import javax.xml.stream.XMLEventWriter; +import javax.xml.stream.XMLInputFactory; +import javax.xml.stream.XMLOutputFactory; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.events.XMLEvent; + +import org.junit.Test; + +import static org.custommonkey.xmlunit.XMLAssert.assertXMLEqual; + +/** + * @author Arjen Poutsma + */ +public class ListBasedXMLEventReaderTests { + + private final XMLInputFactory inputFactory = XMLInputFactory.newFactory(); + + private final XMLOutputFactory outputFactory = XMLOutputFactory.newFactory(); + + @Test + public void standard() throws Exception { + String xml = "baz"; + List events = readEvents(xml); + + ListBasedXMLEventReader reader = new ListBasedXMLEventReader(events); + + StringWriter resultWriter = new StringWriter(); + XMLEventWriter writer = this.outputFactory.createXMLEventWriter(resultWriter); + writer.add(reader); + + assertXMLEqual(xml, resultWriter.toString()); + } + + private List readEvents(String xml) throws XMLStreamException { + XMLEventReader reader = + this.inputFactory.createXMLEventReader(new StringReader(xml)); + List events = new ArrayList<>(); + while (reader.hasNext()) { + events.add(reader.nextEvent()); + } + return events; + } + +} \ No newline at end of file diff --git a/spring-web-reactive/src/test/java/org/springframework/web/client/reactive/DefaultWebRequestBuilderTests.java b/spring-web-reactive/src/test/java/org/springframework/web/client/reactive/DefaultWebRequestBuilderTests.java new file mode 100644 index 0000000000..ba7364c315 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/client/reactive/DefaultWebRequestBuilderTests.java @@ -0,0 +1,60 @@ +/* + * Copyright 2002-2016 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.client.reactive; + +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.*; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +import org.springframework.http.HttpMethod; + +/** + * + * @author Rob Winch + */ +public class DefaultWebRequestBuilderTests { + private DefaultClientWebRequestBuilder builder; + + @Before + public void setup() { + builder = new DefaultClientWebRequestBuilder(HttpMethod.GET, "https://example.com/foo"); + } + + @Test + public void apply() { + ClientWebRequestPostProcessor postProcessor = mock(ClientWebRequestPostProcessor.class); + when(postProcessor.postProcess(any(ClientWebRequest.class))).thenAnswer(new Answer() { + @Override + public ClientWebRequest answer(InvocationOnMock invocation) throws Throwable { + return (ClientWebRequest) invocation.getArguments()[0]; + } + }); + + ClientWebRequest webRequest = builder.apply(postProcessor).build(); + + verify(postProcessor).postProcess(webRequest); + } + + @Test(expected = IllegalArgumentException.class) + public void applyNullPostProcessorThrowsIllegalArgumentException() { + builder.apply(null); + } +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/client/reactive/RxJava1WebClientIntegrationTests.java b/spring-web-reactive/src/test/java/org/springframework/web/client/reactive/RxJava1WebClientIntegrationTests.java new file mode 100644 index 0000000000..97ebe3eed0 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/client/reactive/RxJava1WebClientIntegrationTests.java @@ -0,0 +1,340 @@ +/* + * Copyright 2002-2016 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.client.reactive; + +import static org.junit.Assert.*; +import static org.springframework.web.client.reactive.support.RxJava1ClientWebRequestBuilders.*; +import static org.springframework.web.client.reactive.support.RxJava1ResponseExtractors.*; + +import java.util.concurrent.TimeUnit; + +import okhttp3.HttpUrl; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.hamcrest.Matchers; +import org.junit.After; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; +import rx.Observable; +import rx.Single; +import rx.observers.TestSubscriber; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.http.codec.Pojo; + +/** + * {@link WebClient} integration tests with the {@code Obserable} and {@code Single} API. + * + * @author Brian Clozel + */ +public class RxJava1WebClientIntegrationTests { + + private MockWebServer server; + + private WebClient webClient; + + @Before + public void setup() { + this.server = new MockWebServer(); + this.webClient = new WebClient(new ReactorClientHttpConnector()); + } + + @Test + public void shouldGetHeaders() throws Exception { + + HttpUrl baseUrl = server.url("/greeting?name=Spring"); + this.server.enqueue(new MockResponse().setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); + + Single result = this.webClient + .perform(get(baseUrl.toString())) + .extract(headers()); + + TestSubscriber ts = new TestSubscriber(); + result.subscribe(ts); + ts.awaitTerminalEvent(2, TimeUnit.SECONDS); + + HttpHeaders httpHeaders = ts.getOnNextEvents().get(0); + assertEquals(MediaType.TEXT_PLAIN, httpHeaders.getContentType()); + assertEquals(13L, httpHeaders.getContentLength()); + ts.assertValueCount(1); + ts.assertCompleted(); + + RecordedRequest request = server.takeRequest(); + assertEquals(1, server.getRequestCount()); + assertEquals("*/*", request.getHeader(HttpHeaders.ACCEPT)); + assertEquals("/greeting?name=Spring", request.getPath()); + } + + @Test + public void shouldGetPlainTextResponseAsObject() throws Exception { + + HttpUrl baseUrl = server.url("/greeting?name=Spring"); + this.server.enqueue(new MockResponse().setBody("Hello Spring!")); + + Single result = this.webClient + .perform(get(baseUrl.toString()) + .header("X-Test-Header", "testvalue")) + .extract(body(String.class)); + + TestSubscriber ts = new TestSubscriber(); + result.subscribe(ts); + ts.awaitTerminalEvent(2, TimeUnit.SECONDS); + + String response = ts.getOnNextEvents().get(0); + assertEquals("Hello Spring!", response); + ts.assertValueCount(1); + ts.assertCompleted(); + + RecordedRequest request = server.takeRequest(); + assertEquals(1, server.getRequestCount()); + assertEquals("testvalue", request.getHeader("X-Test-Header")); + assertEquals("*/*", request.getHeader(HttpHeaders.ACCEPT)); + assertEquals("/greeting?name=Spring", request.getPath()); + } + + @Test + public void shouldGetPlainTextResponse() throws Exception { + + HttpUrl baseUrl = server.url("/greeting?name=Spring"); + this.server.enqueue(new MockResponse().setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); + + Single> result = this.webClient + .perform(get(baseUrl.toString()) + .accept(MediaType.TEXT_PLAIN)) + .extract(response(String.class)); + + TestSubscriber> ts = new TestSubscriber>(); + result.subscribe(ts); + ts.awaitTerminalEvent(2, TimeUnit.SECONDS); + + ResponseEntity response = ts.getOnNextEvents().get(0); + assertEquals(200, response.getStatusCode().value()); + assertEquals(MediaType.TEXT_PLAIN, response.getHeaders().getContentType()); + assertEquals("Hello Spring!", response.getBody()); + ts.assertValueCount(1); + ts.assertCompleted(); + + RecordedRequest request = server.takeRequest(); + assertEquals(1, server.getRequestCount()); + assertEquals("/greeting?name=Spring", request.getPath()); + assertEquals("text/plain", request.getHeader(HttpHeaders.ACCEPT)); + } + + @Test + public void shouldGetJsonAsMonoOfString() throws Exception { + + HttpUrl baseUrl = server.url("/json"); + String content = "{\"bar\":\"barbar\",\"foo\":\"foofoo\"}"; + this.server.enqueue(new MockResponse().setHeader("Content-Type", "application/json") + .setBody(content)); + + Single result = this.webClient + .perform(get(baseUrl.toString()) + .accept(MediaType.APPLICATION_JSON)) + .extract(body(String.class)); + + TestSubscriber ts = new TestSubscriber(); + result.subscribe(ts); + ts.awaitTerminalEvent(2, TimeUnit.SECONDS); + + String response = ts.getOnNextEvents().get(0); + assertEquals(content, response); + ts.assertValueCount(1); + ts.assertCompleted(); + + RecordedRequest request = server.takeRequest(); + assertEquals(1, server.getRequestCount()); + assertEquals("/json", request.getPath()); + assertEquals("application/json", request.getHeader(HttpHeaders.ACCEPT)); + } + + @Test + public void shouldGetJsonAsMonoOfPojo() throws Exception { + + HttpUrl baseUrl = server.url("/pojo"); + this.server.enqueue(new MockResponse().setHeader("Content-Type", "application/json") + .setBody("{\"bar\":\"barbar\",\"foo\":\"foofoo\"}")); + + Single result = this.webClient + .perform(get(baseUrl.toString()) + .accept(MediaType.APPLICATION_JSON)) + .extract(body(Pojo.class)); + + TestSubscriber ts = new TestSubscriber(); + result.subscribe(ts); + ts.awaitTerminalEvent(2, TimeUnit.SECONDS); + + Pojo response = ts.getOnNextEvents().get(0); + assertEquals("barbar", response.getBar()); + ts.assertValueCount(1); + ts.assertCompleted(); + + RecordedRequest request = server.takeRequest(); + assertEquals(1, server.getRequestCount()); + assertEquals("/pojo", request.getPath()); + assertEquals("application/json", request.getHeader(HttpHeaders.ACCEPT)); + } + + @Test + public void shouldGetJsonAsFluxOfPojos() throws Exception { + + HttpUrl baseUrl = server.url("/pojos"); + this.server.enqueue(new MockResponse().setHeader("Content-Type", "application/json") + .setBody("[{\"bar\":\"bar1\",\"foo\":\"foo1\"},{\"bar\":\"bar2\",\"foo\":\"foo2\"}]")); + + Observable result = this.webClient + .perform(get(baseUrl.toString()) + .accept(MediaType.APPLICATION_JSON)) + .extract(bodyStream(Pojo.class)); + + TestSubscriber ts = new TestSubscriber(); + result.subscribe(ts); + ts.awaitTerminalEvent(2, TimeUnit.SECONDS); + + assertThat(ts.getOnNextEvents().get(0).getBar(), Matchers.is("bar1")); + assertThat(ts.getOnNextEvents().get(1).getBar(), Matchers.is("bar2")); + ts.assertValueCount(2); + ts.assertCompleted(); + + RecordedRequest request = server.takeRequest(); + assertEquals(1, server.getRequestCount()); + assertEquals("/pojos", request.getPath()); + assertEquals("application/json", request.getHeader(HttpHeaders.ACCEPT)); + } + + @Test + public void shouldGetJsonAsResponseOfPojosStream() throws Exception { + + HttpUrl baseUrl = server.url("/pojos"); + this.server.enqueue(new MockResponse().setHeader("Content-Type", "application/json") + .setBody("[{\"bar\":\"bar1\",\"foo\":\"foo1\"},{\"bar\":\"bar2\",\"foo\":\"foo2\"}]")); + + Single>> result = this.webClient + .perform(get(baseUrl.toString()) + .accept(MediaType.APPLICATION_JSON)) + .extract(responseStream(Pojo.class)); + + TestSubscriber>> ts = new TestSubscriber>>(); + result.subscribe(ts); + ts.awaitTerminalEvent(2, TimeUnit.SECONDS); + + ResponseEntity> response = ts.getOnNextEvents().get(0); + assertEquals(200, response.getStatusCode().value()); + assertEquals(MediaType.APPLICATION_JSON, response.getHeaders().getContentType()); + ts.assertValueCount(1); + ts.assertCompleted(); + + RecordedRequest request = server.takeRequest(); + assertEquals(1, server.getRequestCount()); + assertEquals("/pojos", request.getPath()); + assertEquals("application/json", request.getHeader(HttpHeaders.ACCEPT)); + } + + @Test + public void shouldPostPojoAsJson() throws Exception { + + HttpUrl baseUrl = server.url("/pojo/capitalize"); + this.server.enqueue(new MockResponse() + .setHeader("Content-Type", "application/json") + .setBody("{\"bar\":\"BARBAR\",\"foo\":\"FOOFOO\"}")); + + Pojo spring = new Pojo("foofoo", "barbar"); + Single result = this.webClient + .perform(post(baseUrl.toString()) + .body(spring) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .extract(body(Pojo.class)); + + TestSubscriber ts = new TestSubscriber(); + result.subscribe(ts); + ts.awaitTerminalEvent(2, TimeUnit.SECONDS); + + assertThat(ts.getOnNextEvents().get(0).getBar(), Matchers.is("BARBAR")); + ts.assertValueCount(1); + ts.assertCompleted(); + + RecordedRequest request = server.takeRequest(); + assertEquals(1, server.getRequestCount()); + assertEquals("/pojo/capitalize", request.getPath()); + assertEquals("{\"foo\":\"foofoo\",\"bar\":\"barbar\"}", request.getBody().readUtf8()); + assertEquals("chunked", request.getHeader(HttpHeaders.TRANSFER_ENCODING)); + assertEquals("application/json", request.getHeader(HttpHeaders.ACCEPT)); + assertEquals("application/json", request.getHeader(HttpHeaders.CONTENT_TYPE)); + } + + @Test + public void shouldSendCookieHeader() throws Exception { + HttpUrl baseUrl = server.url("/test"); + this.server.enqueue(new MockResponse() + .setHeader("Content-Type", "text/plain").setBody("test")); + + Single result = this.webClient + .perform(get(baseUrl.toString()) + .cookie("testkey", "testvalue")) + .extract(body(String.class)); + + TestSubscriber ts = new TestSubscriber(); + result.subscribe(ts); + ts.awaitTerminalEvent(2, TimeUnit.SECONDS); + + String response = ts.getOnNextEvents().get(0); + assertEquals("test", response); + ts.assertValueCount(1); + ts.assertCompleted(); + + RecordedRequest request = server.takeRequest(); + assertEquals(1, server.getRequestCount()); + assertEquals("/test", request.getPath()); + assertEquals("testkey=testvalue", request.getHeader(HttpHeaders.COOKIE)); + } + + @Test + @Ignore + public void shouldGetErrorWhen404() throws Exception { + + HttpUrl baseUrl = server.url("/greeting?name=Spring"); + this.server.enqueue(new MockResponse().setResponseCode(404)); + + Single result = this.webClient + .perform(get(baseUrl.toString())) + .extract(body(String.class)); + + // TODO: error message should be converted to a ClientException + TestSubscriber ts = new TestSubscriber(); + result.subscribe(ts); + ts.awaitTerminalEvent(2, TimeUnit.SECONDS); + + ts.assertError(WebClientException.class); + + RecordedRequest request = server.takeRequest(); + assertEquals(1, server.getRequestCount()); + assertEquals("*/*", request.getHeader(HttpHeaders.ACCEPT)); + assertEquals("/greeting?name=Spring", request.getPath()); + } + + @After + public void tearDown() throws Exception { + this.server.shutdown(); + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/client/reactive/WebClientIntegrationTests.java b/spring-web-reactive/src/test/java/org/springframework/web/client/reactive/WebClientIntegrationTests.java new file mode 100644 index 0000000000..88d97aa84e --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/client/reactive/WebClientIntegrationTests.java @@ -0,0 +1,307 @@ +/* + * Copyright 2002-2016 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.client.reactive; + +import static org.junit.Assert.*; +import static org.springframework.web.client.reactive.ClientWebRequestBuilders.*; +import static org.springframework.web.client.reactive.ResponseExtractors.*; + +import java.util.function.Consumer; + +import okhttp3.HttpUrl; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.hamcrest.Matchers; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.test.TestSubscriber; + +import org.springframework.http.codec.Pojo; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; + +/** + * {@link WebClient} integration tests with the {@code Flux} and {@code Mono} API. + * + * @author Brian Clozel + */ +public class WebClientIntegrationTests { + + private MockWebServer server; + + private WebClient webClient; + + @Before + public void setup() { + this.server = new MockWebServer(); + this.webClient = new WebClient(new ReactorClientHttpConnector()); + } + + @Test + public void shouldGetHeaders() throws Exception { + + HttpUrl baseUrl = server.url("/greeting?name=Spring"); + this.server.enqueue(new MockResponse().setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); + + Mono result = this.webClient + .perform(get(baseUrl.toString())) + .extract(headers()); + + TestSubscriber + .subscribe(result) + .awaitAndAssertNextValuesWith( + httpHeaders -> { + assertEquals(MediaType.TEXT_PLAIN, httpHeaders.getContentType()); + assertEquals(13L, httpHeaders.getContentLength()); + }) + .assertComplete(); + + RecordedRequest request = server.takeRequest(); + assertEquals(1, server.getRequestCount()); + assertEquals("*/*", request.getHeader(HttpHeaders.ACCEPT)); + assertEquals("/greeting?name=Spring", request.getPath()); + } + + @Test + public void shouldGetPlainTextResponseAsObject() throws Exception { + + HttpUrl baseUrl = server.url("/greeting?name=Spring"); + this.server.enqueue(new MockResponse().setBody("Hello Spring!")); + + Mono result = this.webClient + .perform(get(baseUrl.toString()) + .header("X-Test-Header", "testvalue")) + .extract(body(String.class)); + + + TestSubscriber + .subscribe(result) + .awaitAndAssertNextValues("Hello Spring!") + .assertComplete(); + + RecordedRequest request = server.takeRequest(); + assertEquals(1, server.getRequestCount()); + assertEquals("testvalue", request.getHeader("X-Test-Header")); + assertEquals("*/*", request.getHeader(HttpHeaders.ACCEPT)); + assertEquals("/greeting?name=Spring", request.getPath()); + } + + @Test + public void shouldGetPlainTextResponse() throws Exception { + + HttpUrl baseUrl = server.url("/greeting?name=Spring"); + this.server.enqueue(new MockResponse().setHeader("Content-Type", "text/plain").setBody("Hello Spring!")); + + Mono> result = this.webClient + .perform(get(baseUrl.toString()) + .accept(MediaType.TEXT_PLAIN)) + .extract(response(String.class)); + + TestSubscriber + .subscribe(result) + .awaitAndAssertNextValuesWith((Consumer>) response -> { + assertEquals(200, response.getStatusCode().value()); + assertEquals(MediaType.TEXT_PLAIN, response.getHeaders().getContentType()); + assertEquals("Hello Spring!", response.getBody()); + }); + RecordedRequest request = server.takeRequest(); + assertEquals(1, server.getRequestCount()); + assertEquals("/greeting?name=Spring", request.getPath()); + assertEquals("text/plain", request.getHeader(HttpHeaders.ACCEPT)); + } + + @Test + public void shouldGetJsonAsMonoOfString() throws Exception { + + HttpUrl baseUrl = server.url("/json"); + String content = "{\"bar\":\"barbar\",\"foo\":\"foofoo\"}"; + this.server.enqueue(new MockResponse().setHeader("Content-Type", "application/json") + .setBody(content)); + + Mono result = this.webClient + .perform(get(baseUrl.toString()) + .accept(MediaType.APPLICATION_JSON)) + .extract(body(String.class)); + + TestSubscriber + .subscribe(result) + .awaitAndAssertNextValues(content) + .assertComplete(); + RecordedRequest request = server.takeRequest(); + assertEquals(1, server.getRequestCount()); + assertEquals("/json", request.getPath()); + assertEquals("application/json", request.getHeader(HttpHeaders.ACCEPT)); + } + + @Test + public void shouldGetJsonAsMonoOfPojo() throws Exception { + + HttpUrl baseUrl = server.url("/pojo"); + this.server.enqueue(new MockResponse().setHeader("Content-Type", "application/json") + .setBody("{\"bar\":\"barbar\",\"foo\":\"foofoo\"}")); + + Mono result = this.webClient + .perform(get(baseUrl.toString()) + .accept(MediaType.APPLICATION_JSON)) + .extract(body(Pojo.class)); + + TestSubscriber + .subscribe(result) + .awaitAndAssertNextValuesWith(p -> assertEquals("barbar", p.getBar())) + .assertComplete(); + RecordedRequest request = server.takeRequest(); + assertEquals(1, server.getRequestCount()); + assertEquals("/pojo", request.getPath()); + assertEquals("application/json", request.getHeader(HttpHeaders.ACCEPT)); + } + + @Test + public void shouldGetJsonAsFluxOfPojos() throws Exception { + + HttpUrl baseUrl = server.url("/pojos"); + this.server.enqueue(new MockResponse().setHeader("Content-Type", "application/json") + .setBody("[{\"bar\":\"bar1\",\"foo\":\"foo1\"},{\"bar\":\"bar2\",\"foo\":\"foo2\"}]")); + + Flux result = this.webClient + .perform(get(baseUrl.toString()) + .accept(MediaType.APPLICATION_JSON)) + .extract(bodyStream(Pojo.class)); + + TestSubscriber + .subscribe(result) + .awaitAndAssertNextValuesWith( + p -> assertThat(p.getBar(), Matchers.is("bar1")), + p -> assertThat(p.getBar(), Matchers.is("bar2"))) + .assertValueCount(2) + .assertComplete(); + RecordedRequest request = server.takeRequest(); + assertEquals(1, server.getRequestCount()); + assertEquals("/pojos", request.getPath()); + assertEquals("application/json", request.getHeader(HttpHeaders.ACCEPT)); + } + + @Test + public void shouldGetJsonAsResponseOfPojosStream() throws Exception { + + HttpUrl baseUrl = server.url("/pojos"); + this.server.enqueue(new MockResponse().setHeader("Content-Type", "application/json") + .setBody("[{\"bar\":\"bar1\",\"foo\":\"foo1\"},{\"bar\":\"bar2\",\"foo\":\"foo2\"}]")); + + Mono>> result = this.webClient + .perform(get(baseUrl.toString()) + .accept(MediaType.APPLICATION_JSON)) + .extract(responseStream(Pojo.class)); + + TestSubscriber + .subscribe(result) + .awaitAndAssertNextValuesWith( + response -> { + assertEquals(200, response.getStatusCode().value()); + assertEquals(MediaType.APPLICATION_JSON, response.getHeaders().getContentType()); + }) + .assertComplete(); + RecordedRequest request = server.takeRequest(); + assertEquals(1, server.getRequestCount()); + assertEquals("/pojos", request.getPath()); + assertEquals("application/json", request.getHeader(HttpHeaders.ACCEPT)); + } + + @Test + public void shouldPostPojoAsJson() throws Exception { + + HttpUrl baseUrl = server.url("/pojo/capitalize"); + this.server.enqueue(new MockResponse() + .setHeader("Content-Type", "application/json") + .setBody("{\"bar\":\"BARBAR\",\"foo\":\"FOOFOO\"}")); + + Pojo spring = new Pojo("foofoo", "barbar"); + Mono result = this.webClient + .perform(post(baseUrl.toString()) + .body(spring) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .extract(body(Pojo.class)); + + TestSubscriber + .subscribe(result) + .awaitAndAssertNextValuesWith(p -> assertEquals("BARBAR", p.getBar())) + .assertComplete(); + + RecordedRequest request = server.takeRequest(); + assertEquals(1, server.getRequestCount()); + assertEquals("/pojo/capitalize", request.getPath()); + assertEquals("{\"foo\":\"foofoo\",\"bar\":\"barbar\"}", request.getBody().readUtf8()); + assertEquals("chunked", request.getHeader(HttpHeaders.TRANSFER_ENCODING)); + assertEquals("application/json", request.getHeader(HttpHeaders.ACCEPT)); + assertEquals("application/json", request.getHeader(HttpHeaders.CONTENT_TYPE)); + } + + @Test + public void shouldSendCookieHeader() throws Exception { + HttpUrl baseUrl = server.url("/test"); + this.server.enqueue(new MockResponse() + .setHeader("Content-Type", "text/plain").setBody("test")); + + Mono result = this.webClient + .perform(get(baseUrl.toString()) + .cookie("testkey", "testvalue")) + .extract(body(String.class)); + + TestSubscriber + .subscribe(result) + .awaitAndAssertNextValues("test") + .assertComplete(); + + RecordedRequest request = server.takeRequest(); + assertEquals(1, server.getRequestCount()); + assertEquals("/test", request.getPath()); + assertEquals("testkey=testvalue", request.getHeader(HttpHeaders.COOKIE)); + } + + @Test + public void shouldGetErrorWhen404() throws Exception { + + HttpUrl baseUrl = server.url("/greeting?name=Spring"); + this.server.enqueue(new MockResponse().setResponseCode(404)); + + Mono result = this.webClient + .perform(get(baseUrl.toString())) + .extract(body(String.class)); + + TestSubscriber + .subscribe(result) + .await() + .assertError(); + + RecordedRequest request = server.takeRequest(); + assertEquals(1, server.getRequestCount()); + assertEquals("*/*", request.getHeader(HttpHeaders.ACCEPT)); + assertEquals("/greeting?name=Spring", request.getPath()); + } + + @After + public void tearDown() throws Exception { + this.server.shutdown(); + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/DispatcherHandlerErrorTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/DispatcherHandlerErrorTests.java new file mode 100644 index 0000000000..65c721844e --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/DispatcherHandlerErrorTests.java @@ -0,0 +1,253 @@ +/* + * Copyright 2002-2016 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.reactive; + +import java.net.URI; +import java.time.Duration; +import java.util.Collections; + +import org.junit.Before; +import org.junit.Test; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Mono; +import reactor.core.test.TestSubscriber; + +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.codec.StringEncoder; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.converter.reactive.CodecHttpMessageConverter; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerAdapter; +import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerMapping; +import org.springframework.web.reactive.result.method.annotation.ResponseBodyResultHandler; +import org.springframework.web.server.NotAcceptableStatusException; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.ServerWebInputException; +import org.springframework.web.server.WebExceptionHandler; +import org.springframework.web.server.WebHandler; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.handler.ExceptionHandlingWebHandler; +import org.springframework.web.server.session.MockWebSessionManager; + +import static org.hamcrest.CoreMatchers.startsWith; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThat; + +/** + * Test the effect of exceptions at different stages of request processing by + * checking the error signals on the completion publisher. + * + * @author Rossen Stoyanchev + */ +@SuppressWarnings({"ThrowableResultOfMethodCallIgnored", "ThrowableInstanceNeverThrown"}) +public class DispatcherHandlerErrorTests { + + private static final IllegalStateException EXCEPTION = new IllegalStateException("boo"); + + + private DispatcherHandler dispatcherHandler; + + private MockServerHttpRequest request; + + private ServerWebExchange exchange; + + + @Before + public void setUp() throws Exception { + AnnotationConfigApplicationContext appContext = new AnnotationConfigApplicationContext(); + appContext.register(TestConfig.class); + appContext.refresh(); + + this.dispatcherHandler = new DispatcherHandler(); + this.dispatcherHandler.setApplicationContext(appContext); + + this.request = new MockServerHttpRequest(HttpMethod.GET, new URI("/")); + MockServerHttpResponse response = new MockServerHttpResponse(); + MockWebSessionManager sessionManager = new MockWebSessionManager(); + this.exchange = new DefaultServerWebExchange(this.request, response, sessionManager); + } + + + @Test + public void noHandler() throws Exception { + this.request.setUri(new URI("/does-not-exist")); + Mono publisher = this.dispatcherHandler.handle(this.exchange); + + TestSubscriber.subscribe(publisher) + .assertError(ResponseStatusException.class) + .assertErrorMessage("Request failure [status: 404, reason: \"No matching handler\"]"); + } + + @Test + public void unknownMethodArgumentType() throws Exception { + this.request.setUri(new URI("/unknown-argument-type")); + Mono publisher = this.dispatcherHandler.handle(this.exchange); + + TestSubscriber.subscribe(publisher) + .assertError(IllegalStateException.class) + .assertErrorWith(ex -> assertThat(ex.getMessage(), startsWith("No resolver for argument [0]"))); + } + + @Test + public void controllerReturnsMonoError() throws Exception { + this.request.setUri(new URI("/error-signal")); + Mono publisher = this.dispatcherHandler.handle(this.exchange); + + TestSubscriber.subscribe(publisher) + .assertErrorWith(ex -> assertSame(EXCEPTION, ex)); + } + + @Test + public void controllerThrowsException() throws Exception { + this.request.setUri(new URI("/raise-exception")); + Mono publisher = this.dispatcherHandler.handle(this.exchange); + + TestSubscriber.subscribe(publisher) + .assertErrorWith(ex -> assertSame(EXCEPTION, ex)); + } + + @Test + public void unknownReturnType() throws Exception { + this.request.setUri(new URI("/unknown-return-type")); + Mono publisher = this.dispatcherHandler.handle(this.exchange); + + TestSubscriber.subscribe(publisher) + .assertError(IllegalStateException.class) + .assertErrorWith(ex -> assertThat(ex.getMessage(), startsWith("No HandlerResultHandler"))); + } + + @Test + public void responseBodyMessageConversionError() throws Exception { + DataBuffer dataBuffer = new DefaultDataBufferFactory().allocateBuffer(); + this.request.setUri(new URI("/request-body")); + this.request.getHeaders().add("Accept", MediaType.APPLICATION_JSON_VALUE); + this.request.writeWith(Mono.just(dataBuffer.write("body".getBytes("UTF-8")))); + + Mono publisher = this.dispatcherHandler.handle(this.exchange); + + TestSubscriber.subscribe(publisher) + .assertError(NotAcceptableStatusException.class); + } + + @Test + public void requestBodyError() throws Exception { + this.request.setUri(new URI("/request-body")); + this.request.writeWith(Mono.error(EXCEPTION)); + Mono publisher = this.dispatcherHandler.handle(this.exchange); + + TestSubscriber.subscribe(publisher) + .assertError(ServerWebInputException.class) + .assertErrorWith(ex -> assertSame(EXCEPTION, ex.getCause())); + } + + @Test + public void webExceptionHandler() throws Exception { + this.request.setUri(new URI("/unknown-argument-type")); + + WebExceptionHandler exceptionHandler = new ServerError500ExceptionHandler(); + WebHandler webHandler = new ExceptionHandlingWebHandler(this.dispatcherHandler, exceptionHandler); + webHandler.handle(this.exchange).block(Duration.ofSeconds(5)); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, this.exchange.getResponse().getStatusCode()); + } + + + @Configuration + @SuppressWarnings({"unused", "WeakerAccess"}) + static class TestConfig { + + @Bean + public RequestMappingHandlerMapping handlerMapping() { + return new RequestMappingHandlerMapping(); + } + + @Bean + public RequestMappingHandlerAdapter handlerAdapter() { + return new RequestMappingHandlerAdapter(); + } + + @Bean + public ResponseBodyResultHandler resultHandler() { + return new ResponseBodyResultHandler( + Collections.singletonList(new CodecHttpMessageConverter<>(new StringEncoder())), + new DefaultConversionService()); + } + + @Bean + public TestController testController() { + return new TestController(); + } + } + + @Controller + @SuppressWarnings("unused") + private static class TestController { + + @RequestMapping("/unknown-argument-type") + public void unknownArgumentType(Foo arg) { + } + + @RequestMapping("/error-signal") + @ResponseBody + public Publisher errorSignal() { + return Mono.error(EXCEPTION); + } + + @RequestMapping("/raise-exception") + public void raiseException() throws Exception { + throw EXCEPTION; + } + + @RequestMapping("/unknown-return-type") + public Foo unknownReturnType() throws Exception { + return new Foo(); + } + + @RequestMapping("/request-body") + @ResponseBody + public Publisher requestBody(@RequestBody Publisher body) { + return Mono.from(body).map(s -> "hello " + s); + } + } + + private static class Foo { + } + + private static class ServerError500ExceptionHandler implements WebExceptionHandler { + + @Override + public Mono handle(ServerWebExchange exchange, Throwable ex) { + exchange.getResponse().setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR); + return Mono.empty(); + } + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/ResponseStatusExceptionHandlerTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/ResponseStatusExceptionHandlerTests.java new file mode 100644 index 0000000000..704a713008 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/ResponseStatusExceptionHandlerTests.java @@ -0,0 +1,80 @@ +/* + * Copyright 2002-2015 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.reactive; + +import java.net.URI; +import java.time.Duration; + +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Mono; +import reactor.core.test.TestSubscriber; + +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; + +/** + * Unit tests for {@link ResponseStatusExceptionHandler}. + * + * @author Rossen Stoyanchev + */ +public class ResponseStatusExceptionHandlerTests { + + private ResponseStatusExceptionHandler handler; + + private MockServerHttpResponse response; + + private ServerWebExchange exchange; + + + @Before + public void setUp() throws Exception { + this.handler = new ResponseStatusExceptionHandler(); + this.response = new MockServerHttpResponse(); + this.exchange = new DefaultServerWebExchange( + new MockServerHttpRequest(HttpMethod.GET, new URI("/path")), + this.response, + new MockWebSessionManager()); + } + + + @Test + public void handleException() throws Exception { + Throwable ex = new ResponseStatusException(HttpStatus.BAD_REQUEST, ""); + this.handler.handle(this.exchange, ex).block(Duration.ofSeconds(5)); + + assertEquals(HttpStatus.BAD_REQUEST, this.response.getStatusCode()); + } + + @Test + public void unresolvedException() throws Exception { + Throwable expected = new IllegalStateException(); + Mono mono = this.handler.handle(this.exchange, expected); + + TestSubscriber.subscribe(mono) + .assertErrorWith(actual -> assertSame(expected, actual)); + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/accept/CompositeContentTypeResolverBuilderTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/accept/CompositeContentTypeResolverBuilderTests.java new file mode 100644 index 0000000000..74fd266380 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/accept/CompositeContentTypeResolverBuilderTests.java @@ -0,0 +1,196 @@ +/* + * Copyright 2002-2016 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.reactive.accept; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Collections; + +import org.junit.Test; + +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.web.server.NotAcceptableStatusException; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; +import org.springframework.web.server.session.WebSessionManager; + +import static org.junit.Assert.assertEquals; + +/** + * Unit tests for {@link RequestedContentTypeResolverBuilder}. + * + * @author Rossen Stoyanchev + */ +public class CompositeContentTypeResolverBuilderTests { + + @Test + public void defaultSettings() throws Exception { + RequestedContentTypeResolver resolver = new RequestedContentTypeResolverBuilder().build(); + + ServerWebExchange exchange = createExchange("/flower.gif"); + + assertEquals("Should be able to resolve file extensions by default", + Collections.singletonList(MediaType.IMAGE_GIF), resolver.resolveMediaTypes(exchange)); + + exchange = createExchange("/flower.xyz"); + + assertEquals("Should ignore unknown extensions by default", + Collections.emptyList(), resolver.resolveMediaTypes(exchange)); + + exchange = createExchange("/flower"); + exchange.getRequest().getQueryParams().add("format", "gif"); + + assertEquals("Should not resolve request parameters by default", + Collections.emptyList(), resolver.resolveMediaTypes(exchange)); + + exchange = createExchange("/flower"); + exchange.getRequest().getHeaders().setAccept(Collections.singletonList(MediaType.IMAGE_GIF)); + + assertEquals("Should resolve Accept header by default", + Collections.singletonList(MediaType.IMAGE_GIF), resolver.resolveMediaTypes(exchange)); + } + + @Test + public void favorPath() throws Exception { + RequestedContentTypeResolver resolver = new RequestedContentTypeResolverBuilder() + .favorPathExtension(true) + .mediaType("foo", new MediaType("application", "foo")) + .mediaType("bar", new MediaType("application", "bar")) + .build(); + + ServerWebExchange exchange = createExchange("/flower.foo"); + assertEquals(Collections.singletonList(new MediaType("application", "foo")), + resolver.resolveMediaTypes(exchange)); + + exchange = createExchange("/flower.bar"); + assertEquals(Collections.singletonList(new MediaType("application", "bar")), + resolver.resolveMediaTypes(exchange)); + + exchange = createExchange("/flower.gif"); + assertEquals(Collections.singletonList(MediaType.IMAGE_GIF), resolver.resolveMediaTypes(exchange)); + } + + @Test + public void favorPathWithJafTurnedOff() throws Exception { + RequestedContentTypeResolver resolver = new RequestedContentTypeResolverBuilder() + .favorPathExtension(true) + .useJaf(false) + .build(); + + ServerWebExchange exchange = createExchange("/flower.foo"); + assertEquals(Collections.emptyList(), resolver.resolveMediaTypes(exchange)); + + exchange = createExchange("/flower.gif"); + assertEquals(Collections.emptyList(), resolver.resolveMediaTypes(exchange)); + } + + @Test(expected = NotAcceptableStatusException.class) // SPR-10170 + public void favorPathWithIgnoreUnknownPathExtensionTurnedOff() throws Exception { + RequestedContentTypeResolver resolver = new RequestedContentTypeResolverBuilder() + .favorPathExtension(true) + .ignoreUnknownPathExtensions(false) + .build(); + + ServerWebExchange exchange = createExchange("/flower.xyz"); + exchange.getRequest().getQueryParams().add("format", "json"); + + resolver.resolveMediaTypes(exchange); + } + + @Test + public void favorParameter() throws Exception { + RequestedContentTypeResolver resolver = new RequestedContentTypeResolverBuilder() + .favorParameter(true) + .mediaType("json", MediaType.APPLICATION_JSON) + .build(); + + ServerWebExchange exchange = createExchange("/flower"); + exchange.getRequest().getQueryParams().add("format", "json"); + + assertEquals(Collections.singletonList(MediaType.APPLICATION_JSON), + resolver.resolveMediaTypes(exchange)); + } + + @Test(expected = NotAcceptableStatusException.class) // SPR-10170 + public void favorParameterWithUnknownMediaType() throws Exception { + RequestedContentTypeResolver resolver = new RequestedContentTypeResolverBuilder() + .favorParameter(true) + .build(); + + ServerWebExchange exchange = createExchange("/flower"); + exchange.getRequest().getQueryParams().add("format", "xyz"); + + resolver.resolveMediaTypes(exchange); + } + + @Test + public void ignoreAcceptHeader() throws Exception { + RequestedContentTypeResolver resolver = new RequestedContentTypeResolverBuilder() + .ignoreAcceptHeader(true) + .build(); + + ServerWebExchange exchange = createExchange("/flower"); + exchange.getRequest().getHeaders().setAccept(Collections.singletonList(MediaType.IMAGE_GIF)); + + assertEquals(Collections.emptyList(), resolver.resolveMediaTypes(exchange)); + } + + @Test // SPR-10513 + public void setDefaultContentType() throws Exception { + RequestedContentTypeResolver resolver = new RequestedContentTypeResolverBuilder() + .defaultContentType(MediaType.APPLICATION_JSON) + .build(); + + ServerWebExchange exchange = createExchange("/"); + + assertEquals(Collections.singletonList(MediaType.APPLICATION_JSON), + resolver.resolveMediaTypes(exchange)); + + exchange.getRequest().getHeaders().setAccept(Collections.singletonList(MediaType.ALL)); + + assertEquals(Collections.singletonList(MediaType.APPLICATION_JSON), + resolver.resolveMediaTypes(exchange)); + } + + @Test // SPR-12286 + public void setDefaultContentTypeWithStrategy() throws Exception { + RequestedContentTypeResolver resolver = new RequestedContentTypeResolverBuilder() + .defaultContentTypeResolver(new FixedContentTypeResolver(MediaType.APPLICATION_JSON)) + .build(); + + ServerWebExchange exchange = createExchange("/"); + + assertEquals(Collections.singletonList(MediaType.APPLICATION_JSON), + resolver.resolveMediaTypes(exchange)); + + exchange.getRequest().getHeaders().setAccept(Collections.singletonList(MediaType.ALL)); + assertEquals(Collections.singletonList(MediaType.APPLICATION_JSON), + resolver.resolveMediaTypes(exchange)); + } + + + private ServerWebExchange createExchange(String path) throws URISyntaxException { + ServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, new URI(path)); + WebSessionManager sessionManager = new MockWebSessionManager(); + return new DefaultServerWebExchange(request, new MockServerHttpResponse(), sessionManager); + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/accept/HeaderContentTypeResolverTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/accept/HeaderContentTypeResolverTests.java new file mode 100644 index 0000000000..71072f535c --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/accept/HeaderContentTypeResolverTests.java @@ -0,0 +1,82 @@ +/* + * Copyright 2002-2016 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.reactive.accept; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.List; + +import org.junit.Before; +import org.junit.Test; + +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.web.server.NotAcceptableStatusException; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; +import org.springframework.web.server.session.WebSessionManager; + +import static org.junit.Assert.assertEquals; + +/** + * Unit tests for {@link HeaderContentTypeResolver}. + * + * @author Rossen Stoyanchev + */ +public class HeaderContentTypeResolverTests { + + private HeaderContentTypeResolver resolver; + + + @Before + public void setup() { + this.resolver = new HeaderContentTypeResolver(); + } + + + @Test + public void resolveMediaTypes() throws Exception { + ServerWebExchange exchange = createExchange("text/plain; q=0.5, text/html, text/x-dvi; q=0.8, text/x-c"); + List mediaTypes = this.resolver.resolveMediaTypes(exchange); + + assertEquals(4, mediaTypes.size()); + assertEquals("text/html", mediaTypes.get(0).toString()); + assertEquals("text/x-c", mediaTypes.get(1).toString()); + assertEquals("text/x-dvi;q=0.8", mediaTypes.get(2).toString()); + assertEquals("text/plain;q=0.5", mediaTypes.get(3).toString()); + } + + @Test(expected=NotAcceptableStatusException.class) + public void resolveMediaTypesParseError() throws Exception { + ServerWebExchange exchange = createExchange("textplain; q=0.5"); + this.resolver.resolveMediaTypes(exchange); + } + + + private ServerWebExchange createExchange(String accept) throws URISyntaxException { + ServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, new URI("/")); + if (accept != null) { + request.getHeaders().add("Accept", accept); + } + WebSessionManager sessionManager = new MockWebSessionManager(); + return new DefaultServerWebExchange(request, new MockServerHttpResponse(), sessionManager); + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/accept/MappingContentTypeResolverTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/accept/MappingContentTypeResolverTests.java new file mode 100644 index 0000000000..6c2cd5a30a --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/accept/MappingContentTypeResolverTests.java @@ -0,0 +1,122 @@ +/* + * Copyright 2002-2016 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.reactive.accept; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.Test; + +import org.springframework.http.MediaType; +import org.springframework.web.server.ServerWebExchange; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Unit tests for {@link AbstractMappingContentTypeResolver}. + * @author Rossen Stoyanchev + */ +public class MappingContentTypeResolverTests { + + @Test + public void resolveExtensions() { + Map mapping = Collections.singletonMap("json", MediaType.APPLICATION_JSON); + TestMappingContentTypeResolver resolver = new TestMappingContentTypeResolver("", mapping); + Set keys = resolver.getKeysFor(MediaType.APPLICATION_JSON); + + assertEquals(1, keys.size()); + assertEquals("json", keys.iterator().next()); + } + + @Test + public void resolveExtensionsNoMatch() { + Map mapping = Collections.singletonMap("json", MediaType.APPLICATION_JSON); + TestMappingContentTypeResolver resolver = new TestMappingContentTypeResolver("", mapping); + Set keys = resolver.getKeysFor(MediaType.TEXT_HTML); + + assertTrue(keys.isEmpty()); + } + + @Test // SPR-13747 + public void lookupMediaTypeCaseInsensitive() { + Map mapping = Collections.singletonMap("json", MediaType.APPLICATION_JSON); + TestMappingContentTypeResolver resolver = new TestMappingContentTypeResolver("", mapping); + MediaType mediaType = resolver.getMediaType("JSoN"); + + assertEquals(mediaType, MediaType.APPLICATION_JSON); + } + + @Test + public void resolveMediaTypes() throws Exception { + Map mapping = Collections.singletonMap("json", MediaType.APPLICATION_JSON); + TestMappingContentTypeResolver resolver = new TestMappingContentTypeResolver("json", mapping); + List mediaTypes = resolver.resolveMediaTypes((ServerWebExchange) null); + + assertEquals(1, mediaTypes.size()); + assertEquals("application/json", mediaTypes.get(0).toString()); + } + + @Test + public void resolveMediaTypesNoMatch() throws Exception { + TestMappingContentTypeResolver resolver = new TestMappingContentTypeResolver("blah", null); + List mediaTypes = resolver.resolveMediaTypes((ServerWebExchange) null); + + assertEquals(0, mediaTypes.size()); + } + + @Test + public void resolveMediaTypesNoKey() throws Exception { + Map mapping = Collections.singletonMap("json", MediaType.APPLICATION_JSON); + TestMappingContentTypeResolver resolver = new TestMappingContentTypeResolver(null, mapping); + List mediaTypes = resolver.resolveMediaTypes((ServerWebExchange) null); + + assertEquals(0, mediaTypes.size()); + } + + @Test + public void resolveMediaTypesHandleNoMatch() throws Exception { + TestMappingContentTypeResolver resolver = new TestMappingContentTypeResolver("xml", null); + List mediaTypes = resolver.resolveMediaTypes((ServerWebExchange) null); + + assertEquals(1, mediaTypes.size()); + assertEquals("application/xml", mediaTypes.get(0).toString()); + } + + + private static class TestMappingContentTypeResolver extends AbstractMappingContentTypeResolver { + + private final String key; + + public TestMappingContentTypeResolver(String key, Map mapping) { + super(mapping); + this.key = key; + } + + @Override + protected String extractKey(ServerWebExchange exchange) { + return this.key; + } + + @Override + protected MediaType handleNoMatch(String mappingKey) { + return "xml".equals(mappingKey) ? MediaType.APPLICATION_XML : null; + } + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/accept/PathExtensionContentTypeResolverTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/accept/PathExtensionContentTypeResolverTests.java new file mode 100644 index 0000000000..c789fc99c4 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/accept/PathExtensionContentTypeResolverTests.java @@ -0,0 +1,119 @@ +/* + * Copyright 2002-2016 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.reactive.accept; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.junit.Test; + +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.web.server.NotAcceptableStatusException; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; +import org.springframework.web.server.session.WebSessionManager; + +import static org.junit.Assert.assertEquals; + +/** + * Unit tests for {@link PathExtensionContentTypeResolver}. + * + * @author Rossen Stoyanchev + */ +public class PathExtensionContentTypeResolverTests { + + @Test + public void resolveMediaTypesFromMapping() throws Exception { + ServerWebExchange exchange = createExchange("/test.html"); + PathExtensionContentTypeResolver resolver = new PathExtensionContentTypeResolver(); + List mediaTypes = resolver.resolveMediaTypes(exchange); + + assertEquals(Collections.singletonList(new MediaType("text", "html")), mediaTypes); + + Map mapping = Collections.singletonMap("HTML", MediaType.APPLICATION_XHTML_XML); + resolver = new PathExtensionContentTypeResolver(mapping); + mediaTypes = resolver.resolveMediaTypes(exchange); + + assertEquals(Collections.singletonList(new MediaType("application", "xhtml+xml")), mediaTypes); + } + + @Test + public void resolveMediaTypesFromJaf() throws Exception { + ServerWebExchange exchange = createExchange("test.xls"); + PathExtensionContentTypeResolver resolver = new PathExtensionContentTypeResolver(); + List mediaTypes = resolver.resolveMediaTypes(exchange); + + assertEquals(Collections.singletonList(new MediaType("application", "vnd.ms-excel")), mediaTypes); + } + + // SPR-10334 + + @Test + public void getMediaTypeFromFilenameNoJaf() throws Exception { + ServerWebExchange exchange = createExchange("test.json"); + PathExtensionContentTypeResolver resolver = new PathExtensionContentTypeResolver(); + resolver.setUseJaf(false); + List mediaTypes = resolver.resolveMediaTypes(exchange); + + assertEquals(Collections.emptyList(), mediaTypes); + } + + // SPR-9390 + + @Test + public void getMediaTypeFilenameWithEncodedURI() throws Exception { + ServerWebExchange exchange = createExchange("/quo%20vadis%3f.html"); + PathExtensionContentTypeResolver resolver = new PathExtensionContentTypeResolver(); + List result = resolver.resolveMediaTypes(exchange); + + assertEquals("Invalid content type", Collections.singletonList(new MediaType("text", "html")), result); + } + + // SPR-10170 + + @Test + public void resolveMediaTypesIgnoreUnknownExtension() throws Exception { + ServerWebExchange exchange = createExchange("test.xyz"); + PathExtensionContentTypeResolver resolver = new PathExtensionContentTypeResolver(); + List mediaTypes = resolver.resolveMediaTypes(exchange); + + assertEquals(Collections.emptyList(), mediaTypes); + } + + @Test(expected = NotAcceptableStatusException.class) + public void resolveMediaTypesDoNotIgnoreUnknownExtension() throws Exception { + ServerWebExchange exchange = createExchange("test.xyz"); + PathExtensionContentTypeResolver resolver = new PathExtensionContentTypeResolver(); + resolver.setIgnoreUnknownExtensions(false); + resolver.resolveMediaTypes(exchange); + } + + + private ServerWebExchange createExchange(String path) throws URISyntaxException { + ServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, new URI(path)); + WebSessionManager sessionManager = new MockWebSessionManager(); + return new DefaultServerWebExchange(request, new MockServerHttpResponse(), sessionManager); + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/config/ViewResolverRegistryTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/config/ViewResolverRegistryTests.java new file mode 100644 index 0000000000..c5035730f8 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/config/ViewResolverRegistryTests.java @@ -0,0 +1,90 @@ +/* + * Copyright 2002-2016 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.reactive.config; + +import org.junit.Before; +import org.junit.Test; + +import org.springframework.core.Ordered; +import org.springframework.http.codec.json.JacksonJsonEncoder; +import org.springframework.web.context.support.StaticWebApplicationContext; +import org.springframework.web.reactive.result.view.HttpMessageConverterView; +import org.springframework.web.reactive.result.view.UrlBasedViewResolver; +import org.springframework.web.reactive.result.view.View; +import org.springframework.web.reactive.result.view.freemarker.FreeMarkerConfigurer; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +/** + * Unit tests for {@link ViewResolverRegistry}. + * + * @author Rossen Stoyanchev + */ +public class ViewResolverRegistryTests { + + private ViewResolverRegistry registry; + + + @Before + public void setUp() { + StaticWebApplicationContext context = new StaticWebApplicationContext(); + context.registerSingleton("freeMarkerConfigurer", FreeMarkerConfigurer.class); + this.registry = new ViewResolverRegistry(context); + } + + @Test + public void order() { + assertEquals(Ordered.LOWEST_PRECEDENCE, this.registry.getOrder()); + } + + @Test + public void hasRegistrations() { + assertFalse(this.registry.hasRegistrations()); + + this.registry.freeMarker(); + assertTrue(this.registry.hasRegistrations()); + } + + @Test + public void noResolvers() { + assertNotNull(this.registry.getViewResolvers()); + assertEquals(0, this.registry.getViewResolvers().size()); + assertFalse(this.registry.hasRegistrations()); + } + + @Test + public void customViewResolver() { + UrlBasedViewResolver viewResolver = new UrlBasedViewResolver(); + this.registry.viewResolver(viewResolver); + + assertSame(viewResolver, this.registry.getViewResolvers().get(0)); + assertEquals(1, this.registry.getViewResolvers().size()); + } + + @Test + public void defaultViews() throws Exception { + View view = new HttpMessageConverterView(new JacksonJsonEncoder()); + this.registry.defaultViews(view); + + assertEquals(1, this.registry.getDefaultViews().size()); + assertSame(view, this.registry.getDefaultViews().get(0)); + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/config/WebReactiveConfigurationTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/config/WebReactiveConfigurationTests.java new file mode 100644 index 0000000000..25b75c0680 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/config/WebReactiveConfigurationTests.java @@ -0,0 +1,325 @@ +/* + * Copyright 2002-2016 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.reactive.config; + +import java.net.URI; +import java.nio.ByteBuffer; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import javax.xml.bind.annotation.XmlRootElement; + +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import rx.Observable; + +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.StringDecoder; +import org.springframework.core.codec.StringEncoder; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.codec.json.JacksonJsonEncoder; +import org.springframework.http.codec.xml.Jaxb2Decoder; +import org.springframework.http.codec.xml.Jaxb2Encoder; +import org.springframework.http.converter.reactive.CodecHttpMessageConverter; +import org.springframework.http.converter.reactive.HttpMessageConverter; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils; +import org.springframework.validation.Validator; +import org.springframework.validation.beanvalidation.OptionalValidatorFactoryBean; +import org.springframework.web.reactive.accept.RequestedContentTypeResolver; +import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerAdapter; +import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerMapping; +import org.springframework.web.reactive.result.method.annotation.ResponseBodyResultHandler; +import org.springframework.web.reactive.result.method.annotation.ResponseEntityResultHandler; +import org.springframework.web.reactive.result.view.HttpMessageConverterView; +import org.springframework.web.reactive.result.view.View; +import org.springframework.web.reactive.result.view.ViewResolutionResultHandler; +import org.springframework.web.reactive.result.view.ViewResolver; +import org.springframework.web.reactive.result.view.freemarker.FreeMarkerConfigurer; +import org.springframework.web.reactive.result.view.freemarker.FreeMarkerViewResolver; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.http.MediaType.APPLICATION_OCTET_STREAM; +import static org.springframework.http.MediaType.APPLICATION_XML; +import static org.springframework.http.MediaType.IMAGE_PNG; +import static org.springframework.http.MediaType.TEXT_PLAIN; + +/** + * Unit tests for {@link WebReactiveConfiguration}. + * @author Rossen Stoyanchev + */ +public class WebReactiveConfigurationTests { + + private MockServerHttpRequest request; + + private ServerWebExchange exchange; + + + @Before + public void setUp() throws Exception { + this.request = new MockServerHttpRequest(HttpMethod.GET, new URI("/")); + MockServerHttpResponse response = new MockServerHttpResponse(); + this.exchange = new DefaultServerWebExchange(this.request, response, new MockWebSessionManager()); + } + + + @Test + public void requestMappingHandlerMapping() throws Exception { + ApplicationContext context = loadConfig(WebReactiveConfiguration.class); + + String name = "requestMappingHandlerMapping"; + RequestMappingHandlerMapping mapping = context.getBean(name, RequestMappingHandlerMapping.class); + assertNotNull(mapping); + + assertEquals(0, mapping.getOrder()); + + assertTrue(mapping.useSuffixPatternMatch()); + assertTrue(mapping.useTrailingSlashMatch()); + assertTrue(mapping.useRegisteredSuffixPatternMatch()); + + name = "mvcContentTypeResolver"; + RequestedContentTypeResolver resolver = context.getBean(name, RequestedContentTypeResolver.class); + assertSame(resolver, mapping.getContentTypeResolver()); + + this.request.setUri(new URI("/path.json")); + List list = Collections.singletonList(MediaType.APPLICATION_JSON); + assertEquals(list, resolver.resolveMediaTypes(this.exchange)); + + this.request.setUri(new URI("/path.xml")); + assertEquals(Collections.emptyList(), resolver.resolveMediaTypes(this.exchange)); + } + + @Test + public void customPathMatchConfig() throws Exception { + ApplicationContext context = loadConfig(CustomPatchMatchConfig.class); + + String name = "requestMappingHandlerMapping"; + RequestMappingHandlerMapping mapping = context.getBean(name, RequestMappingHandlerMapping.class); + assertNotNull(mapping); + + assertFalse(mapping.useSuffixPatternMatch()); + assertFalse(mapping.useTrailingSlashMatch()); + } + + @Test + public void requestMappingHandlerAdapter() throws Exception { + ApplicationContext context = loadConfig(WebReactiveConfiguration.class); + + String name = "requestMappingHandlerAdapter"; + RequestMappingHandlerAdapter adapter = context.getBean(name, RequestMappingHandlerAdapter.class); + assertNotNull(adapter); + + List> converters = adapter.getMessageConverters(); + assertEquals(6, converters.size()); + + assertHasConverter(converters, ByteBuffer.class, APPLICATION_OCTET_STREAM, APPLICATION_OCTET_STREAM); + assertHasConverter(converters, String.class, TEXT_PLAIN, TEXT_PLAIN); + assertHasConverter(converters, Resource.class, IMAGE_PNG, IMAGE_PNG); + assertHasConverter(converters, TestBean.class, APPLICATION_XML, APPLICATION_XML); + assertHasConverter(converters, TestBean.class, APPLICATION_JSON, APPLICATION_JSON); + assertHasConverter(converters, TestBean.class, null, MediaType.parseMediaType("text/event-stream")); + + name = "mvcConversionService"; + ConversionService service = context.getBean(name, ConversionService.class); + assertSame(service, adapter.getConversionService()); + + name = "mvcValidator"; + Validator validator = context.getBean(name, Validator.class); + assertSame(validator, adapter.getValidator()); + assertEquals(OptionalValidatorFactoryBean.class, validator.getClass()); + } + + @Test + public void customMessageConverterConfig() throws Exception { + ApplicationContext context = loadConfig(CustomMessageConverterConfig.class); + + String name = "requestMappingHandlerAdapter"; + RequestMappingHandlerAdapter adapter = context.getBean(name, RequestMappingHandlerAdapter.class); + assertNotNull(adapter); + + List> converters = adapter.getMessageConverters(); + assertEquals(2, converters.size()); + + assertHasConverter(converters, String.class, TEXT_PLAIN, TEXT_PLAIN); + assertHasConverter(converters, TestBean.class, APPLICATION_XML, APPLICATION_XML); + } + + @Test + public void mvcConversionService() throws Exception { + ApplicationContext context = loadConfig(WebReactiveConfiguration.class); + + String name = "mvcConversionService"; + ConversionService service = context.getBean(name, ConversionService.class); + assertNotNull(service); + + service.canConvert(CompletableFuture.class, Mono.class); + service.canConvert(Observable.class, Flux.class); + } + + @Test + public void responseEntityResultHandler() throws Exception { + ApplicationContext context = loadConfig(WebReactiveConfiguration.class); + + String name = "responseEntityResultHandler"; + ResponseEntityResultHandler handler = context.getBean(name, ResponseEntityResultHandler.class); + assertNotNull(handler); + + assertEquals(0, handler.getOrder()); + + List> converters = handler.getMessageConverters(); + assertEquals(6, converters.size()); + + assertHasConverter(converters, ByteBuffer.class, APPLICATION_OCTET_STREAM, APPLICATION_OCTET_STREAM); + assertHasConverter(converters, String.class, TEXT_PLAIN, TEXT_PLAIN); + assertHasConverter(converters, Resource.class, IMAGE_PNG, IMAGE_PNG); + assertHasConverter(converters, TestBean.class, APPLICATION_XML, APPLICATION_XML); + assertHasConverter(converters, TestBean.class, APPLICATION_JSON, APPLICATION_JSON); + assertHasConverter(converters, TestBean.class, null, MediaType.parseMediaType("text/event-stream")); + + name = "mvcContentTypeResolver"; + RequestedContentTypeResolver resolver = context.getBean(name, RequestedContentTypeResolver.class); + assertSame(resolver, handler.getContentTypeResolver()); + } + + @Test + public void responseBodyResultHandler() throws Exception { + ApplicationContext context = loadConfig(WebReactiveConfiguration.class); + + String name = "responseBodyResultHandler"; + ResponseBodyResultHandler handler = context.getBean(name, ResponseBodyResultHandler.class); + assertNotNull(handler); + + assertEquals(100, handler.getOrder()); + + List> converters = handler.getMessageConverters(); + assertEquals(6, converters.size()); + + assertHasConverter(converters, ByteBuffer.class, APPLICATION_OCTET_STREAM, APPLICATION_OCTET_STREAM); + assertHasConverter(converters, String.class, TEXT_PLAIN, TEXT_PLAIN); + assertHasConverter(converters, Resource.class, IMAGE_PNG, IMAGE_PNG); + assertHasConverter(converters, TestBean.class, APPLICATION_XML, APPLICATION_XML); + assertHasConverter(converters, TestBean.class, APPLICATION_JSON, APPLICATION_JSON); + assertHasConverter(converters, TestBean.class, null, MediaType.parseMediaType("text/event-stream")); + + name = "mvcContentTypeResolver"; + RequestedContentTypeResolver resolver = context.getBean(name, RequestedContentTypeResolver.class); + assertSame(resolver, handler.getContentTypeResolver()); + } + + @Test + public void viewResolutionResultHandler() throws Exception { + ApplicationContext context = loadConfig(CustomViewResolverConfig.class); + + String name = "viewResolutionResultHandler"; + ViewResolutionResultHandler handler = context.getBean(name, ViewResolutionResultHandler.class); + assertNotNull(handler); + + assertEquals(Ordered.LOWEST_PRECEDENCE, handler.getOrder()); + + List resolvers = handler.getViewResolvers(); + assertEquals(1, resolvers.size()); + assertEquals(FreeMarkerViewResolver.class, resolvers.get(0).getClass()); + + List views = handler.getDefaultViews(); + assertEquals(1, views.size()); + + MimeType type = MimeTypeUtils.parseMimeType("application/json;charset=UTF-8"); + assertEquals(type, views.get(0).getSupportedMediaTypes().get(0)); + } + + + private void assertHasConverter(List> converters, Class clazz, + MediaType readMediaType, MediaType writeMediaType) { + ResolvableType type = ResolvableType.forClass(clazz); + assertTrue(converters.stream() + .filter(c -> (readMediaType == null || c.canRead(type, readMediaType)) + && (writeMediaType == null || c.canWrite(type, writeMediaType))) + .findAny() + .isPresent()); + } + + private ApplicationContext loadConfig(Class... configurationClasses) { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.register(configurationClasses); + context.refresh(); + return context; + } + + + @Configuration + static class CustomPatchMatchConfig extends WebReactiveConfiguration { + + @Override + public void configurePathMatching(PathMatchConfigurer configurer) { + configurer.setUseSuffixPatternMatch(false); + configurer.setUseTrailingSlashMatch(false); + } + } + + @Configuration + static class CustomMessageConverterConfig extends WebReactiveConfiguration { + + @Override + protected void configureMessageConverters(List> converters) { + converters.add(new CodecHttpMessageConverter<>(new StringEncoder(), new StringDecoder())); + } + + @Override + protected void extendMessageConverters(List> converters) { + converters.add(new CodecHttpMessageConverter<>(new Jaxb2Encoder(), new Jaxb2Decoder())); + } + } + + @Configuration @SuppressWarnings("unused") + static class CustomViewResolverConfig extends WebReactiveConfiguration { + + @Override + protected void configureViewResolvers(ViewResolverRegistry registry) { + registry.freeMarker(); + registry.defaultViews(new HttpMessageConverterView(new JacksonJsonEncoder())); + } + + @Bean + public FreeMarkerConfigurer freeMarkerConfig() { + return new FreeMarkerConfigurer(); + } + + } + + @XmlRootElement + static class TestBean { + } +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/handler/SimpleUrlHandlerMappingTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/handler/SimpleUrlHandlerMappingTests.java new file mode 100644 index 0000000000..bb1b405f16 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/handler/SimpleUrlHandlerMappingTests.java @@ -0,0 +1,155 @@ +/* + * Copyright 2002-2016 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.reactive.handler; + +import java.net.URI; +import java.net.URISyntaxException; + +import org.junit.Test; + +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.support.ClassPathXmlApplicationContext; +import org.springframework.http.HttpMethod; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.web.reactive.HandlerMapping; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; +import org.springframework.web.server.session.WebSessionManager; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.springframework.web.reactive.HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE; + +/** + * Unit tests for {@link SimpleUrlHandlerMapping}. + * + * @author Rossen Stoyanchev + */ +public class SimpleUrlHandlerMappingTests { + + @Test + public void handlerMappingJavaConfig() throws Exception { + AnnotationConfigApplicationContext wac = new AnnotationConfigApplicationContext(); + wac.register(WebConfig.class); + wac.refresh(); + + HandlerMapping handlerMapping = (HandlerMapping) wac.getBean("handlerMapping"); + Object mainController = wac.getBean("mainController"); + Object otherController = wac.getBean("otherController"); + + testUrl("/welcome.html", mainController, handlerMapping, "/welcome.html"); + testUrl("/welcome.x", otherController, handlerMapping, "welcome.x"); + testUrl("/welcome/", otherController, handlerMapping, "welcome"); + testUrl("/show.html", mainController, handlerMapping, "/show.html"); + testUrl("/bookseats.html", mainController, handlerMapping, "/bookseats.html"); + } + + @Test + public void handlerMappingXmlConfig() throws Exception { + ClassPathXmlApplicationContext wac = new ClassPathXmlApplicationContext("map.xml", getClass()); + wac.refresh(); + + HandlerMapping handlerMapping = wac.getBean("mapping", HandlerMapping.class); + Object mainController = wac.getBean("mainController"); + + testUrl("/pathmatchingTest.html", mainController, handlerMapping, "pathmatchingTest.html"); + testUrl("welcome.html", null, handlerMapping, null); + testUrl("/pathmatchingAA.html", mainController, handlerMapping, "pathmatchingAA.html"); + testUrl("/pathmatchingA.html", null, handlerMapping, null); + testUrl("/administrator/pathmatching.html", mainController, handlerMapping, "pathmatching.html"); + testUrl("/administrator/test/pathmatching.html", mainController, handlerMapping, "test/pathmatching.html"); + testUrl("/administratort/pathmatching.html", null, handlerMapping, null); + testUrl("/administrator/another/bla.xml", mainController, handlerMapping, "/administrator/another/bla.xml"); + testUrl("/administrator/another/bla.gif", null, handlerMapping, null); + testUrl("/administrator/test/testlastbit", mainController, handlerMapping, "test/testlastbit"); + testUrl("/administrator/test/testla", null, handlerMapping, null); + testUrl("/administrator/testing/longer/bla", mainController, handlerMapping, "bla"); + testUrl("/administrator/testing/longer2/notmatching/notmatching", null, handlerMapping, null); + testUrl("/shortpattern/testing/toolong", null, handlerMapping, null); + testUrl("/XXpathXXmatching.html", mainController, handlerMapping, "XXpathXXmatching.html"); + testUrl("/pathXXmatching.html", mainController, handlerMapping, "pathXXmatching.html"); + testUrl("/XpathXXmatching.html", null, handlerMapping, null); + testUrl("/XXpathmatching.html", null, handlerMapping, null); + testUrl("/show12.html", mainController, handlerMapping, "show12.html"); + testUrl("/show123.html", mainController, handlerMapping, "/show123.html"); + testUrl("/show1.html", mainController, handlerMapping, "show1.html"); + testUrl("/reallyGood-test-is-this.jpeg", mainController, handlerMapping, "reallyGood-test-is-this.jpeg"); + testUrl("/reallyGood-tst-is-this.jpeg", null, handlerMapping, null); + testUrl("/testing/test.jpeg", mainController, handlerMapping, "testing/test.jpeg"); + testUrl("/testing/test.jpg", null, handlerMapping, null); + testUrl("/anotherTest", mainController, handlerMapping, "anotherTest"); + testUrl("/stillAnotherTest", null, handlerMapping, null); + testUrl("outofpattern*ye", null, handlerMapping, null); + testUrl("/test%26t%20est/path%26m%20atching.html", null, handlerMapping, null); + + } + + private void testUrl(String url, Object bean, HandlerMapping handlerMapping, String pathWithinMapping) + throws URISyntaxException { + + ServerWebExchange exchange = createExchange(url); + Object actual = handlerMapping.getHandler(exchange).block(); + if (bean != null) { + assertNotNull(actual); + assertSame(bean, actual); + //noinspection OptionalGetWithoutIsPresent + assertEquals(pathWithinMapping, exchange.getAttribute(PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE).get()); + } + else { + assertNull(actual); + } + } + + private ServerWebExchange createExchange(String path) throws URISyntaxException { + ServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, new URI(path)); + WebSessionManager sessionManager = new MockWebSessionManager(); + return new DefaultServerWebExchange(request, new MockServerHttpResponse(), sessionManager); + } + + + @Configuration + static class WebConfig { + + @Bean @SuppressWarnings("unused") + public SimpleUrlHandlerMapping handlerMapping() { + SimpleUrlHandlerMapping hm = new SimpleUrlHandlerMapping(); + hm.setUseTrailingSlashMatch(true); + hm.registerHandler("/welcome*", otherController()); + hm.registerHandler("/welcome.html", mainController()); + hm.registerHandler("/show.html", mainController()); + hm.registerHandler("/bookseats.html", mainController()); + return hm; + } + + @Bean + public Object mainController() { + return new Object(); + } + + @Bean + public Object otherController() { + return new Object(); + } + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/ContentNegotiatingResultHandlerSupportTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/ContentNegotiatingResultHandlerSupportTests.java new file mode 100644 index 0000000000..08bc361585 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/ContentNegotiatingResultHandlerSupportTests.java @@ -0,0 +1,132 @@ +/* + * Copyright 2002-2016 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.reactive.result; + +import java.net.URI; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import org.junit.Before; +import org.junit.Test; + +import org.springframework.core.convert.support.GenericConversionService; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.web.reactive.accept.FixedContentTypeResolver; +import org.springframework.web.reactive.accept.HeaderContentTypeResolver; +import org.springframework.web.reactive.accept.RequestedContentTypeResolver; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; + +import static org.junit.Assert.assertEquals; +import static org.springframework.http.MediaType.ALL; +import static org.springframework.http.MediaType.APPLICATION_JSON_UTF8; +import static org.springframework.http.MediaType.APPLICATION_OCTET_STREAM; +import static org.springframework.http.MediaType.IMAGE_GIF; +import static org.springframework.http.MediaType.IMAGE_JPEG; +import static org.springframework.http.MediaType.IMAGE_PNG; +import static org.springframework.http.MediaType.TEXT_PLAIN; +import static org.springframework.web.reactive.HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE; + +/** + * Unit tests for {@link ContentNegotiatingResultHandlerSupport}. + * @author Rossen Stoyanchev + */ +public class ContentNegotiatingResultHandlerSupportTests { + + private TestResultHandler resultHandler; + + private MockServerHttpRequest request; + + private ServerWebExchange exchange; + + + @Before + public void setUp() throws Exception { + this.resultHandler = new TestResultHandler(); + this.request = new MockServerHttpRequest(HttpMethod.GET, new URI("/path")); + this.exchange = new DefaultServerWebExchange( + this.request, new MockServerHttpResponse(), new MockWebSessionManager()); + } + + + @Test + public void usesContentTypeResolver() throws Exception { + TestResultHandler resultHandler = new TestResultHandler(new FixedContentTypeResolver(IMAGE_GIF)); + List mediaTypes = Arrays.asList(IMAGE_JPEG, IMAGE_GIF, IMAGE_PNG); + MediaType actual = resultHandler.selectMediaType(this.exchange, mediaTypes); + + assertEquals(IMAGE_GIF, actual); + } + + @Test + public void producibleMediaTypesRequestAttribute() throws Exception { + Set producible = Collections.singleton(IMAGE_GIF); + this.exchange.getAttributes().put(PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE, producible); + + List mediaTypes = Arrays.asList(IMAGE_JPEG, IMAGE_GIF, IMAGE_PNG); + MediaType actual = resultHandler.selectMediaType(this.exchange, mediaTypes); + + assertEquals(IMAGE_GIF, actual); + } + + @Test // SPR-9160 + public void sortsByQuality() throws Exception { + this.request.getHeaders().add("Accept", "text/plain; q=0.5, application/json"); + + List mediaTypes = Arrays.asList(TEXT_PLAIN, APPLICATION_JSON_UTF8); + MediaType actual = this.resultHandler.selectMediaType(this.exchange, mediaTypes); + + assertEquals(APPLICATION_JSON_UTF8, actual); + } + + @Test + public void charsetFromAcceptHeader() throws Exception { + MediaType text8859 = MediaType.parseMediaType("text/plain;charset=ISO-8859-1"); + MediaType textUtf8 = MediaType.parseMediaType("text/plain;charset=UTF-8"); + this.request.getHeaders().setAccept(Collections.singletonList(text8859)); + MediaType actual = this.resultHandler.selectMediaType(this.exchange, Collections.singletonList(textUtf8)); + + assertEquals(text8859, actual); + } + + @Test // SPR-12894 + public void noConcreteMediaType() throws Exception { + List producible = Collections.singletonList(ALL); + MediaType actual = this.resultHandler.selectMediaType(this.exchange, producible); + + assertEquals(APPLICATION_OCTET_STREAM, actual); + } + + + @SuppressWarnings("WeakerAccess") + private static class TestResultHandler extends ContentNegotiatingResultHandlerSupport { + + protected TestResultHandler() { + this(new HeaderContentTypeResolver()); + } + + public TestResultHandler(RequestedContentTypeResolver contentTypeResolver) { + super(new GenericConversionService(), contentTypeResolver); + } + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/ResolvableMethod.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/ResolvableMethod.java new file mode 100644 index 0000000000..0b572755bc --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/ResolvableMethod.java @@ -0,0 +1,241 @@ +/* + * Copyright 2002-2016 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.reactive.result; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.function.Predicate; + +import org.springframework.core.MethodIntrospector; +import org.springframework.core.MethodParameter; +import org.springframework.core.ResolvableType; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; +import org.springframework.util.ReflectionUtils; +import org.springframework.web.reactive.result.method.InvocableHandlerMethod; + +/** + * Convenience class for use in tests to resolve a {@link Method} and/or any of + * its {@link MethodParameter}s based on some hints. + * + *

In tests we often create a class (e.g. TestController) with diverse method + * signatures and annotations to test with. Use of descriptive method and argument + * names combined with using reflection, it becomes challenging to read and write + * tests and it becomes necessary to navigate to the actual method declaration + * which is cumbersome and involves several steps. + * + *

The idea here is to provide enough hints to resolving a method uniquely + * where the hints document exactly what is being tested and there is usually no + * need to navigate to the actual method declaration. For example if testing + * response handling, the return type may be used as a hint: + * + *

+ * ResolvableMethod resolvableMethod = ResolvableMethod.onClass(TestController.class);
+
+ * ResolvableType type = ResolvableType.forClassWithGenerics(Mono.class, View.class);
+ * Method method = resolvableMethod.returning(type).resolve();
+ *
+ * type = ResolvableType.forClassWithGenerics(Mono.class, String.class);
+ * method = resolvableMethod.returning(type).resolve();
+ *
+ * // ...
+ * 
+ * + *

Additional {@code resolve} methods provide options to obtain one of the method + * arguments or return type as a {@link MethodParameter}. + * + * @author Rossen Stoyanchev + */ +public class ResolvableMethod { + + private final Class objectClass; + + private final Object object; + + + private String methodName; + + private Class[] argumentTypes; + + private ResolvableType returnType; + + private final List> annotationTypes = new ArrayList<>(4); + + private final List> predicates = new ArrayList<>(4); + + + + private ResolvableMethod(Class objectClass) { + Assert.notNull(objectClass); + this.objectClass = objectClass; + this.object = null; + } + + private ResolvableMethod(Object object) { + Assert.notNull(object); + this.object = object; + this.objectClass = object.getClass(); + } + + + /** + * Methods that match the given name (regardless of arguments). + */ + public ResolvableMethod name(String methodName) { + this.methodName = methodName; + return this; + } + + /** + * Methods that match the given argument types. + */ + public ResolvableMethod argumentTypes(Class... argumentTypes) { + this.argumentTypes = argumentTypes; + return this; + } + + /** + * Methods declared to return the given type. + */ + public ResolvableMethod returning(ResolvableType resolvableType) { + this.returnType = resolvableType; + return this; + } + + /** + * Methods with the given annotation. + */ + public ResolvableMethod annotated(Class annotationType) { + this.annotationTypes.add(annotationType); + return this; + } + + /** + * Methods matching the given predicate. + */ + public final ResolvableMethod matching(Predicate methodPredicate) { + this.predicates.add(methodPredicate); + return this; + } + + // Resolve methods + + public Method resolve() { + Set methods = MethodIntrospector.selectMethods(this.objectClass, + (ReflectionUtils.MethodFilter) method -> { + if (this.methodName != null && !this.methodName.equals(method.getName())) { + return false; + } + if (getReturnType() != null) { + // String comparison (ResolvableType's with different providers) + String actual = ResolvableType.forMethodReturnType(method).toString(); + if (!actual.equals(getReturnType()) && !Object.class.equals(method.getDeclaringClass())) { + return false; + } + } + else if (!ObjectUtils.isEmpty(this.argumentTypes)) { + if (!Arrays.equals(this.argumentTypes, method.getParameterTypes())) { + return false; + } + } + else if (this.annotationTypes.stream() + .filter(annotType -> AnnotationUtils.findAnnotation(method, annotType) == null) + .findFirst() + .isPresent()) { + return false; + } + else if (this.predicates.stream().filter(p -> !p.test(method)).findFirst().isPresent()) { + return false; + } + return true; + }); + + Assert.isTrue(!methods.isEmpty(), "No matching method: " + this); + Assert.isTrue(methods.size() == 1, "Multiple matching methods: " + this); + + return methods.iterator().next(); + } + + private String getReturnType() { + return this.returnType != null ? this.returnType.toString() : null; + } + + public InvocableHandlerMethod resolveHandlerMethod() { + Assert.notNull(this.object); + return new InvocableHandlerMethod(this.object, resolve()); + } + + public MethodParameter resolveReturnType() { + Method method = resolve(); + return new MethodParameter(method, -1); + } + + @SafeVarargs + public final MethodParameter resolveParam(Predicate... predicates) { + return resolveParam(null, predicates); + } + + @SafeVarargs + public final MethodParameter resolveParam(ResolvableType type, + Predicate... predicates) { + + List matches = new ArrayList<>(); + + Method method = resolve(); + for (int i = 0; i < method.getParameterCount(); i++) { + MethodParameter param = new MethodParameter(method, i); + if (type != null) { + if (!ResolvableType.forMethodParameter(param).toString().equals(type.toString())) { + continue; + } + } + if (!ObjectUtils.isEmpty(predicates)) { + if (Arrays.stream(predicates).filter(p -> !p.test(param)).findFirst().isPresent()) { + continue; + } + } + matches.add(param); + } + + Assert.isTrue(!matches.isEmpty(), "No matching arg on " + method.toString()); + Assert.isTrue(matches.size() == 1, "Multiple matching args: " + matches + " on " + method.toString()); + + return matches.get(0); + } + + @Override + public String toString() { + return "Class=" + this.objectClass + + ", name=" + (this.methodName != null ? this.methodName : "") + + ", returnType=" + (this.returnType != null ? this.returnType : "") + + ", annotations=" + this.annotationTypes; + } + + + public static ResolvableMethod onClass(Class clazz) { + return new ResolvableMethod(clazz); + } + + public static ResolvableMethod on(Object object) { + return new ResolvableMethod(object); + } + +} \ No newline at end of file diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/SimpleResultHandlerTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/SimpleResultHandlerTests.java new file mode 100644 index 0000000000..646956d4ef --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/SimpleResultHandlerTests.java @@ -0,0 +1,102 @@ +/* + * Copyright 2002-2016 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.reactive.result; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import org.junit.Before; +import org.junit.Test; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import rx.Observable; + +import org.springframework.core.MethodParameter; +import org.springframework.core.ResolvableType; +import org.springframework.core.convert.support.MonoToCompletableFutureConverter; +import org.springframework.core.convert.support.ReactorToRxJava1Converter; +import org.springframework.format.support.DefaultFormattingConversionService; +import org.springframework.format.support.FormattingConversionService; +import org.springframework.web.reactive.HandlerResult; + +import static org.junit.Assert.assertEquals; + +/** + * Unit tests for {@link SimpleResultHandler}. + * @author Sebastien Deleuze + * @author Rossen Stoyanchev + */ +public class SimpleResultHandlerTests { + + private SimpleResultHandler resultHandler; + + + @Before + public void setUp() throws Exception { + FormattingConversionService service = new DefaultFormattingConversionService(); + service.addConverter(new MonoToCompletableFutureConverter()); + service.addConverter(new ReactorToRxJava1Converter()); + this.resultHandler = new SimpleResultHandler(service); + } + + + @Test + public void supports() throws NoSuchMethodException { + testSupports(ResolvableType.forClass(void.class), true); + testSupports(ResolvableType.forClassWithGenerics(Publisher.class, Void.class), true); + testSupports(ResolvableType.forClassWithGenerics(Flux.class, Void.class), true); + testSupports(ResolvableType.forClassWithGenerics(Observable.class, Void.class), true); + testSupports(ResolvableType.forClassWithGenerics(CompletableFuture.class, Void.class), true); + + testSupports(ResolvableType.forClass(String.class), false); + testSupports(ResolvableType.forClassWithGenerics(Publisher.class, String.class), false); + } + + @Test + public void supportsUsesGenericTypeInformation() throws Exception { + testSupports(ResolvableType.forClassWithGenerics(List.class, Void.class), false); + } + + private void testSupports(ResolvableType type, boolean result) { + MethodParameter param = ResolvableMethod.onClass(TestController.class).returning(type).resolveReturnType(); + HandlerResult handlerResult = new HandlerResult(new TestController(), null, param); + assertEquals(result, this.resultHandler.supports(handlerResult)); + } + + + @SuppressWarnings("unused") + private static class TestController { + + public void voidReturn() { } + + public Publisher publisherString() { return null; } + + public Flux flux() { return null; } + + public Observable observable() { return null; } + + public CompletableFuture completableFuture() { return null; } + + public String string() { return null; } + + public Publisher publisher() { return null; } + + public List list() { return null; } + + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/SimpleUrlHandlerMappingIntegrationTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/SimpleUrlHandlerMappingIntegrationTests.java new file mode 100644 index 0000000000..5b1c273038 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/SimpleUrlHandlerMappingIntegrationTests.java @@ -0,0 +1,154 @@ +/* + * Copyright 2002-2016 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.reactive.result; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DefaultDataBuffer; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.http.server.reactive.AbstractHttpHandlerIntegrationTests; +import org.springframework.http.server.reactive.HttpHandler; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.reactive.DispatcherHandler; +import org.springframework.web.reactive.ResponseStatusExceptionHandler; +import org.springframework.web.reactive.handler.SimpleUrlHandlerMapping; +import org.springframework.web.server.WebHandler; +import org.springframework.web.server.adapter.WebHttpHandlerBuilder; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; + +/** + * Integration tests with requests mapped via + * {@link SimpleUrlHandlerMapping} to plain {@link WebHandler}s. + * + * @author Rossen Stoyanchev + */ +public class SimpleUrlHandlerMappingIntegrationTests extends AbstractHttpHandlerIntegrationTests { + + @Override + protected HttpHandler createHttpHandler() { + AnnotationConfigApplicationContext wac = new AnnotationConfigApplicationContext(); + wac.register(WebConfig.class); + wac.refresh(); + + DispatcherHandler dispatcherHandler = new DispatcherHandler(); + dispatcherHandler.setApplicationContext(wac); + + return WebHttpHandlerBuilder.webHandler(dispatcherHandler) + .exceptionHandlers(new ResponseStatusExceptionHandler()) + .build(); + } + + @Test + public void testRequestToFooHandler() throws Exception { + URI url = new URI("http://localhost:" + this.port + "/foo"); + RequestEntity request = RequestEntity.get(url).build(); + ResponseEntity response = new RestTemplate().exchange(request, byte[].class); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertArrayEquals("foo".getBytes("UTF-8"), response.getBody()); + } + + @Test + public void testRequestToBarHandler() throws Exception { + URI url = new URI("http://localhost:" + this.port + "/bar"); + RequestEntity request = RequestEntity.get(url).build(); + ResponseEntity response = new RestTemplate().exchange(request, byte[].class); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertArrayEquals("bar".getBytes("UTF-8"), response.getBody()); + } + + @Test + public void testRequestToHeaderSettingHandler() throws Exception { + URI url = new URI("http://localhost:" + this.port + "/header"); + RequestEntity request = RequestEntity.get(url).build(); + ResponseEntity response = new RestTemplate().exchange(request, byte[].class); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals("bar", response.getHeaders().getFirst("foo")); + } + + @Test + public void testHandlerNotFound() throws Exception { + URI url = new URI("http://localhost:" + this.port + "/oops"); + RequestEntity request = RequestEntity.get(url).build(); + try { + new RestTemplate().exchange(request, byte[].class); + } + catch (HttpClientErrorException ex) { + assertEquals(HttpStatus.NOT_FOUND, ex.getStatusCode()); + } + } + + private static DataBuffer asDataBuffer(String text) { + DefaultDataBuffer buffer = new DefaultDataBufferFactory().allocateBuffer(); + return buffer.write(text.getBytes(StandardCharsets.UTF_8)); + } + + + @Configuration + @SuppressWarnings({"unused", "WeakerAccess"}) + static class WebConfig { + + @Bean + public SimpleUrlHandlerMapping handlerMapping() { + return new SimpleUrlHandlerMapping() { + { + Map map = new HashMap<>(); + map.put("/foo", (WebHandler) exchange -> + exchange.getResponse().writeWith(Flux.just(asDataBuffer("foo")))); + map.put("/bar", (WebHandler) exchange -> + exchange.getResponse().writeWith(Flux.just(asDataBuffer("bar")))); + map.put("/header", (WebHandler) exchange -> { + exchange.getResponse().getHeaders().add("foo", "bar"); + return Mono.empty(); + }); + setUrlMap(map); + } + }; + } + + @Bean + public SimpleHandlerAdapter handlerAdapter() { + return new SimpleHandlerAdapter(); + } + + @Bean + public SimpleResultHandler resultHandler() { + return new SimpleResultHandler(new DefaultConversionService()); + } + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/condition/CompositeRequestConditionTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/condition/CompositeRequestConditionTests.java new file mode 100644 index 0000000000..f45e4fbfb3 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/condition/CompositeRequestConditionTests.java @@ -0,0 +1,151 @@ +/* + * Copyright 2002-2016 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.reactive.result.condition; + +import java.net.URI; + +import org.junit.Before; +import org.junit.Test; + +import org.springframework.http.HttpMethod; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; +import org.springframework.web.server.session.WebSessionManager; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; + +/** + * Unit tests for {@link CompositeRequestCondition}. + * + * @author Rossen Stoyanchev + */ +public class CompositeRequestConditionTests { + + private ServerWebExchange exchange; + + private ServerHttpRequest request; + + private ParamsRequestCondition param1; + private ParamsRequestCondition param2; + private ParamsRequestCondition param3; + + private HeadersRequestCondition header1; + private HeadersRequestCondition header2; + private HeadersRequestCondition header3; + + + @Before + public void setup() throws Exception { + WebSessionManager sessionManager = new MockWebSessionManager(); + this.request = new MockServerHttpRequest(HttpMethod.GET, new URI("/")); + this.exchange = new DefaultServerWebExchange(request, new MockServerHttpResponse(), sessionManager); + + this.param1 = new ParamsRequestCondition("param1"); + this.param2 = new ParamsRequestCondition("param2"); + this.param3 = this.param1.combine(this.param2); + + this.header1 = new HeadersRequestCondition("header1"); + this.header2 = new HeadersRequestCondition("header2"); + this.header3 = this.header1.combine(this.header2); + } + + + @Test + public void combine() { + CompositeRequestCondition cond1 = new CompositeRequestCondition(this.param1, this.header1); + CompositeRequestCondition cond2 = new CompositeRequestCondition(this.param2, this.header2); + CompositeRequestCondition cond3 = new CompositeRequestCondition(this.param3, this.header3); + + assertEquals(cond3, cond1.combine(cond2)); + } + + @Test + public void combineEmpty() { + CompositeRequestCondition empty = new CompositeRequestCondition(); + CompositeRequestCondition notEmpty = new CompositeRequestCondition(this.param1); + + assertSame(empty, empty.combine(empty)); + assertSame(notEmpty, notEmpty.combine(empty)); + assertSame(notEmpty, empty.combine(notEmpty)); + } + + @Test(expected=IllegalArgumentException.class) + public void combineDifferentLength() { + CompositeRequestCondition cond1 = new CompositeRequestCondition(this.param1); + CompositeRequestCondition cond2 = new CompositeRequestCondition(this.param1, this.header1); + cond1.combine(cond2); + } + + @Test + public void match() { + this.request.getQueryParams().add("param1", "paramValue1"); + + RequestCondition condition1 = new RequestMethodsRequestCondition(RequestMethod.GET, RequestMethod.POST); + RequestCondition condition2 = new RequestMethodsRequestCondition(RequestMethod.GET); + + CompositeRequestCondition composite1 = new CompositeRequestCondition(this.param1, condition1); + CompositeRequestCondition composite2 = new CompositeRequestCondition(this.param1, condition2); + + assertEquals(composite2, composite1.getMatchingCondition(this.exchange)); + } + + @Test + public void noMatch() { + CompositeRequestCondition cond = new CompositeRequestCondition(this.param1); + assertNull(cond.getMatchingCondition(this.exchange)); + } + + @Test + public void matchEmpty() { + CompositeRequestCondition empty = new CompositeRequestCondition(); + assertSame(empty, empty.getMatchingCondition(this.exchange)); + } + + @Test + public void compare() { + CompositeRequestCondition cond1 = new CompositeRequestCondition(this.param1); + CompositeRequestCondition cond3 = new CompositeRequestCondition(this.param3); + + assertEquals(1, cond1.compareTo(cond3, this.exchange)); + assertEquals(-1, cond3.compareTo(cond1, this.exchange)); + } + + @Test + public void compareEmpty() { + CompositeRequestCondition empty = new CompositeRequestCondition(); + CompositeRequestCondition notEmpty = new CompositeRequestCondition(this.param1); + + assertEquals(0, empty.compareTo(empty, this.exchange)); + assertEquals(-1, notEmpty.compareTo(empty, this.exchange)); + assertEquals(1, empty.compareTo(notEmpty, this.exchange)); + } + + @Test(expected=IllegalArgumentException.class) + public void compareDifferentLength() { + CompositeRequestCondition cond1 = new CompositeRequestCondition(this.param1); + CompositeRequestCondition cond2 = new CompositeRequestCondition(this.param1, this.header1); + cond1.compareTo(cond2, this.exchange); + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/condition/ConsumesRequestConditionTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/condition/ConsumesRequestConditionTests.java new file mode 100644 index 0000000000..ff16024929 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/condition/ConsumesRequestConditionTests.java @@ -0,0 +1,210 @@ +/* + * Copyright 2002-2012 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.reactive.result.condition; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Collection; +import java.util.Collections; + +import org.junit.Test; + +import org.springframework.http.HttpMethod; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.web.reactive.result.condition.ConsumesRequestCondition.ConsumeMediaTypeExpression; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; +import org.springframework.web.server.session.WebSessionManager; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * @author Arjen Poutsma + */ +public class ConsumesRequestConditionTests { + + @Test + public void consumesMatch() throws Exception { + ServerWebExchange exchange = createExchange("text/plain"); + ConsumesRequestCondition condition = new ConsumesRequestCondition("text/plain"); + + assertNotNull(condition.getMatchingCondition(exchange)); + } + + @Test + public void negatedConsumesMatch() throws Exception { + ServerWebExchange exchange = createExchange("text/plain"); + ConsumesRequestCondition condition = new ConsumesRequestCondition("!text/plain"); + + assertNull(condition.getMatchingCondition(exchange)); + } + + @Test + public void getConsumableMediaTypesNegatedExpression() throws Exception { + ConsumesRequestCondition condition = new ConsumesRequestCondition("!application/xml"); + assertEquals(Collections.emptySet(), condition.getConsumableMediaTypes()); + } + + @Test + public void consumesWildcardMatch() throws Exception { + ServerWebExchange exchange = createExchange("text/plain"); + ConsumesRequestCondition condition = new ConsumesRequestCondition("text/*"); + + assertNotNull(condition.getMatchingCondition(exchange)); + } + + @Test + public void consumesMultipleMatch() throws Exception { + ServerWebExchange exchange = createExchange("text/plain"); + ConsumesRequestCondition condition = new ConsumesRequestCondition("text/plain", "application/xml"); + + assertNotNull(condition.getMatchingCondition(exchange)); + } + + @Test + public void consumesSingleNoMatch() throws Exception { + ServerWebExchange exchange = createExchange("application/xml"); + ConsumesRequestCondition condition = new ConsumesRequestCondition("text/plain"); + + assertNull(condition.getMatchingCondition(exchange)); + } + + @Test + public void consumesParseError() throws Exception { + ServerWebExchange exchange = createExchange("01"); + ConsumesRequestCondition condition = new ConsumesRequestCondition("text/plain"); + + assertNull(condition.getMatchingCondition(exchange)); + } + + @Test + public void consumesParseErrorWithNegation() throws Exception { + ServerWebExchange exchange = createExchange("01"); + ConsumesRequestCondition condition = new ConsumesRequestCondition("!text/plain"); + + assertNull(condition.getMatchingCondition(exchange)); + } + + @Test + public void compareToSingle() throws Exception { + ServerWebExchange exchange = createExchange(); + + ConsumesRequestCondition condition1 = new ConsumesRequestCondition("text/plain"); + ConsumesRequestCondition condition2 = new ConsumesRequestCondition("text/*"); + + int result = condition1.compareTo(condition2, exchange); + assertTrue("Invalid comparison result: " + result, result < 0); + + result = condition2.compareTo(condition1, exchange); + assertTrue("Invalid comparison result: " + result, result > 0); + } + + @Test + public void compareToMultiple() throws Exception { + ServerWebExchange exchange = createExchange(); + + ConsumesRequestCondition condition1 = new ConsumesRequestCondition("*/*", "text/plain"); + ConsumesRequestCondition condition2 = new ConsumesRequestCondition("text/*", "text/plain;q=0.7"); + + int result = condition1.compareTo(condition2, exchange); + assertTrue("Invalid comparison result: " + result, result < 0); + + result = condition2.compareTo(condition1, exchange); + assertTrue("Invalid comparison result: " + result, result > 0); + } + + + @Test + public void combine() throws Exception { + ConsumesRequestCondition condition1 = new ConsumesRequestCondition("text/plain"); + ConsumesRequestCondition condition2 = new ConsumesRequestCondition("application/xml"); + + ConsumesRequestCondition result = condition1.combine(condition2); + assertEquals(condition2, result); + } + + @Test + public void combineWithDefault() throws Exception { + ConsumesRequestCondition condition1 = new ConsumesRequestCondition("text/plain"); + ConsumesRequestCondition condition2 = new ConsumesRequestCondition(); + + ConsumesRequestCondition result = condition1.combine(condition2); + assertEquals(condition1, result); + } + + @Test + public void parseConsumesAndHeaders() throws Exception { + String[] consumes = new String[] {"text/plain"}; + String[] headers = new String[]{"foo=bar", "content-type=application/xml,application/pdf"}; + ConsumesRequestCondition condition = new ConsumesRequestCondition(consumes, headers); + + assertConditions(condition, "text/plain", "application/xml", "application/pdf"); + } + + @Test + public void getMatchingCondition() throws Exception { + ServerWebExchange exchange = createExchange("text/plain"); + ConsumesRequestCondition condition = new ConsumesRequestCondition("text/plain", "application/xml"); + + ConsumesRequestCondition result = condition.getMatchingCondition(exchange); + assertConditions(result, "text/plain"); + + condition = new ConsumesRequestCondition("application/xml"); + result = condition.getMatchingCondition(exchange); + assertNull(result); + } + + private void assertConditions(ConsumesRequestCondition condition, String... expected) { + Collection expressions = condition.getContent(); + assertEquals("Invalid amount of conditions", expressions.size(), expected.length); + for (String s : expected) { + boolean found = false; + for (ConsumeMediaTypeExpression expr : expressions) { + String conditionMediaType = expr.getMediaType().toString(); + if (conditionMediaType.equals(s)) { + found = true; + break; + + } + } + if (!found) { + fail("Condition [" + s + "] not found"); + } + } + } + + private ServerWebExchange createExchange() throws URISyntaxException { + return createExchange(null); + } + + private ServerWebExchange createExchange(String contentType) throws URISyntaxException { + ServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, new URI("/")); + if (contentType != null) { + request.getHeaders().add("Content-Type", contentType); + } + WebSessionManager sessionManager = new MockWebSessionManager(); + return new DefaultServerWebExchange(request, new MockServerHttpResponse(), sessionManager); + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/condition/HeadersRequestConditionTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/condition/HeadersRequestConditionTests.java new file mode 100644 index 0000000000..860e6d7001 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/condition/HeadersRequestConditionTests.java @@ -0,0 +1,173 @@ +/* + * Copyright 2002-2012 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.reactive.result.condition; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Collection; + +import org.junit.Test; + +import org.springframework.http.HttpMethod; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; +import org.springframework.web.server.session.WebSessionManager; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * Unit tests for {@link HeadersRequestCondition}. + * + * @author Rossen Stoyanchev + */ +public class HeadersRequestConditionTests { + + @Test + public void headerEquals() { + assertEquals(new HeadersRequestCondition("foo"), new HeadersRequestCondition("foo")); + assertEquals(new HeadersRequestCondition("foo"), new HeadersRequestCondition("FOO")); + assertFalse(new HeadersRequestCondition("foo").equals(new HeadersRequestCondition("bar"))); + assertEquals(new HeadersRequestCondition("foo=bar"), new HeadersRequestCondition("foo=bar")); + assertEquals(new HeadersRequestCondition("foo=bar"), new HeadersRequestCondition("FOO=bar")); + } + + @Test + public void headerPresent() throws Exception { + ServerWebExchange exchange = createExchange("Accept", ""); + HeadersRequestCondition condition = new HeadersRequestCondition("accept"); + + assertNotNull(condition.getMatchingCondition(exchange)); + } + + @Test + public void headerPresentNoMatch() throws Exception { + ServerWebExchange exchange = createExchange("bar", ""); + HeadersRequestCondition condition = new HeadersRequestCondition("foo"); + + assertNull(condition.getMatchingCondition(exchange)); + } + + @Test + public void headerNotPresent() throws Exception { + ServerWebExchange exchange = createExchange(); + HeadersRequestCondition condition = new HeadersRequestCondition("!accept"); + + assertNotNull(condition.getMatchingCondition(exchange)); + } + + @Test + public void headerValueMatch() throws Exception { + ServerWebExchange exchange = createExchange("foo", "bar"); + HeadersRequestCondition condition = new HeadersRequestCondition("foo=bar"); + + assertNotNull(condition.getMatchingCondition(exchange)); + } + + @Test + public void headerValueNoMatch() throws Exception { + ServerWebExchange exchange = createExchange("foo", "bazz"); + HeadersRequestCondition condition = new HeadersRequestCondition("foo=bar"); + + assertNull(condition.getMatchingCondition(exchange)); + } + + @Test + public void headerCaseSensitiveValueMatch() throws Exception { + ServerWebExchange exchange = createExchange("foo", "bar"); + HeadersRequestCondition condition = new HeadersRequestCondition("foo=Bar"); + + assertNull(condition.getMatchingCondition(exchange)); + } + + @Test + public void headerValueMatchNegated() throws Exception { + ServerWebExchange exchange = createExchange("foo", "baz"); + HeadersRequestCondition condition = new HeadersRequestCondition("foo!=bar"); + + assertNotNull(condition.getMatchingCondition(exchange)); + } + + @Test + public void headerValueNoMatchNegated() throws Exception { + ServerWebExchange exchange = createExchange("foo", "bar"); + HeadersRequestCondition condition = new HeadersRequestCondition("foo!=bar"); + + assertNull(condition.getMatchingCondition(exchange)); + } + + @Test + public void compareTo() throws Exception { + ServerWebExchange exchange = createExchange(); + + HeadersRequestCondition condition1 = new HeadersRequestCondition("foo", "bar", "baz"); + HeadersRequestCondition condition2 = new HeadersRequestCondition("foo", "bar"); + + int result = condition1.compareTo(condition2, exchange); + assertTrue("Invalid comparison result: " + result, result < 0); + + result = condition2.compareTo(condition1, exchange); + assertTrue("Invalid comparison result: " + result, result > 0); + } + + + @Test + public void combine() { + HeadersRequestCondition condition1 = new HeadersRequestCondition("foo=bar"); + HeadersRequestCondition condition2 = new HeadersRequestCondition("foo=baz"); + + HeadersRequestCondition result = condition1.combine(condition2); + Collection conditions = result.getContent(); + assertEquals(2, conditions.size()); + } + + @Test + public void getMatchingCondition() throws Exception { + ServerWebExchange exchange = createExchange("foo", "bar"); + HeadersRequestCondition condition = new HeadersRequestCondition("foo"); + + HeadersRequestCondition result = condition.getMatchingCondition(exchange); + assertEquals(condition, result); + + condition = new HeadersRequestCondition("bar"); + + result = condition.getMatchingCondition(exchange); + assertNull(result); + } + + + private ServerWebExchange createExchange() throws URISyntaxException { + return createExchange(null, null); + } + + private ServerWebExchange createExchange(String headerName, String headerValue) throws URISyntaxException { + ServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, new URI("/")); + if (headerName != null) { + request.getHeaders().add(headerName, headerValue); + } + WebSessionManager sessionManager = new MockWebSessionManager(); + return new DefaultServerWebExchange(request, new MockServerHttpResponse(), sessionManager); + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/condition/ParamsRequestConditionTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/condition/ParamsRequestConditionTests.java new file mode 100644 index 0000000000..3a19bda99c --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/condition/ParamsRequestConditionTests.java @@ -0,0 +1,145 @@ +/* + * Copyright 2002-2012 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.reactive.result.condition; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Collection; + +import org.junit.Test; + +import org.springframework.http.HttpMethod; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; +import org.springframework.web.server.session.WebSessionManager; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * @author Arjen Poutsma + */ +public class ParamsRequestConditionTests { + + @Test + public void paramEquals() { + assertEquals(new ParamsRequestCondition("foo"), new ParamsRequestCondition("foo")); + assertFalse(new ParamsRequestCondition("foo").equals(new ParamsRequestCondition("bar"))); + assertFalse(new ParamsRequestCondition("foo").equals(new ParamsRequestCondition("FOO"))); + assertEquals(new ParamsRequestCondition("foo=bar"), new ParamsRequestCondition("foo=bar")); + assertFalse(new ParamsRequestCondition("foo=bar").equals(new ParamsRequestCondition("FOO=bar"))); + } + + @Test + public void paramPresent() throws Exception { + ServerWebExchange exchange = createExchange("foo", ""); + ParamsRequestCondition condition = new ParamsRequestCondition("foo"); + + assertNotNull(condition.getMatchingCondition(exchange)); + } + + @Test + public void paramPresentNoMatch() throws Exception { + ServerWebExchange exchange = createExchange("bar", ""); + ParamsRequestCondition condition = new ParamsRequestCondition("foo"); + + assertNull(condition.getMatchingCondition(exchange)); + } + + @Test + public void paramNotPresent() throws Exception { + ServerWebExchange exchange = createExchange(); + ParamsRequestCondition condition = new ParamsRequestCondition("!foo"); + + assertNotNull(condition.getMatchingCondition(exchange)); + } + + @Test + public void paramValueMatch() throws Exception { + ServerWebExchange exchange = createExchange("foo", "bar"); + ParamsRequestCondition condition = new ParamsRequestCondition("foo=bar"); + + assertNotNull(condition.getMatchingCondition(exchange)); + } + + @Test + public void paramValueNoMatch() throws Exception { + ServerWebExchange exchange = createExchange("foo", "bazz"); + ParamsRequestCondition condition = new ParamsRequestCondition("foo=bar"); + + assertNull(condition.getMatchingCondition(exchange)); + } + + @Test + public void compareTo() throws Exception { + ServerWebExchange exchange = createExchange(); + + ParamsRequestCondition condition1 = new ParamsRequestCondition("foo", "bar", "baz"); + ParamsRequestCondition condition2 = new ParamsRequestCondition("foo", "bar"); + + int result = condition1.compareTo(condition2, exchange); + assertTrue("Invalid comparison result: " + result, result < 0); + + result = condition2.compareTo(condition1, exchange); + assertTrue("Invalid comparison result: " + result, result > 0); + } + + @Test + public void combine() { + ParamsRequestCondition condition1 = new ParamsRequestCondition("foo=bar"); + ParamsRequestCondition condition2 = new ParamsRequestCondition("foo=baz"); + + ParamsRequestCondition result = condition1.combine(condition2); + Collection conditions = result.getContent(); + assertEquals(2, conditions.size()); + } + + @Test + public void getMatchingCondition() throws Exception { + ServerWebExchange exchange = createExchange("foo", "bar"); + ParamsRequestCondition condition = new ParamsRequestCondition("foo"); + + ParamsRequestCondition result = condition.getMatchingCondition(exchange); + assertEquals(condition, result); + + condition = new ParamsRequestCondition("bar"); + + result = condition.getMatchingCondition(exchange); + assertNull(result); + } + + private ServerWebExchange createExchange() throws URISyntaxException { + return createExchange(null, null); + } + + private ServerWebExchange createExchange(String paramName, String paramValue) throws URISyntaxException { + ServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, new URI("/")); + if (paramName != null) { + request.getQueryParams().add(paramName, paramValue); + } + WebSessionManager sessionManager = new MockWebSessionManager(); + return new DefaultServerWebExchange(request, new MockServerHttpResponse(), sessionManager); + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/condition/PatternsRequestConditionTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/condition/PatternsRequestConditionTests.java new file mode 100644 index 0000000000..e153c725df --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/condition/PatternsRequestConditionTests.java @@ -0,0 +1,234 @@ +/* + * Copyright 2002-2016 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.reactive.result.condition; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Collections; +import java.util.Set; + +import org.junit.Test; + +import org.springframework.http.HttpMethod; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; +import org.springframework.web.server.session.WebSessionManager; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +/** + * Unit tests for {@link PatternsRequestCondition}. + * + * @author Rossen Stoyanchev + */ +public class PatternsRequestConditionTests { + + @Test + public void prependSlash() { + PatternsRequestCondition c = new PatternsRequestCondition("foo"); + assertEquals("/foo", c.getPatterns().iterator().next()); + } + + @Test + public void prependNonEmptyPatternsOnly() { + PatternsRequestCondition c = new PatternsRequestCondition(""); + assertEquals("Do not prepend empty patterns (SPR-8255)", "", c.getPatterns().iterator().next()); + } + + @Test + public void combineEmptySets() { + PatternsRequestCondition c1 = new PatternsRequestCondition(); + PatternsRequestCondition c2 = new PatternsRequestCondition(); + + assertEquals(new PatternsRequestCondition(""), c1.combine(c2)); + } + + @Test + public void combineOnePatternWithEmptySet() { + PatternsRequestCondition c1 = new PatternsRequestCondition("/type1", "/type2"); + PatternsRequestCondition c2 = new PatternsRequestCondition(); + + assertEquals(new PatternsRequestCondition("/type1", "/type2"), c1.combine(c2)); + + c1 = new PatternsRequestCondition(); + c2 = new PatternsRequestCondition("/method1", "/method2"); + + assertEquals(new PatternsRequestCondition("/method1", "/method2"), c1.combine(c2)); + } + + @Test + public void combineMultiplePatterns() { + PatternsRequestCondition c1 = new PatternsRequestCondition("/t1", "/t2"); + PatternsRequestCondition c2 = new PatternsRequestCondition("/m1", "/m2"); + + assertEquals(new PatternsRequestCondition("/t1/m1", "/t1/m2", "/t2/m1", "/t2/m2"), c1.combine(c2)); + } + + @Test + public void matchDirectPath() throws Exception { + PatternsRequestCondition condition = new PatternsRequestCondition("/foo"); + PatternsRequestCondition match = condition.getMatchingCondition(createExchange("/foo")); + + assertNotNull(match); + } + + @Test + public void matchPattern() throws Exception { + PatternsRequestCondition condition = new PatternsRequestCondition("/foo/*"); + PatternsRequestCondition match = condition.getMatchingCondition(createExchange("/foo/bar")); + + assertNotNull(match); + } + + @Test + public void matchSortPatterns() throws Exception { + PatternsRequestCondition condition = new PatternsRequestCondition("/**", "/foo/bar", "/foo/*"); + PatternsRequestCondition match = condition.getMatchingCondition(createExchange("/foo/bar")); + PatternsRequestCondition expected = new PatternsRequestCondition("/foo/bar", "/foo/*", "/**"); + + assertEquals(expected, match); + } + + @Test + public void matchSuffixPattern() throws Exception { + ServerWebExchange exchange = createExchange("/foo.html"); + + PatternsRequestCondition condition = new PatternsRequestCondition("/{foo}"); + PatternsRequestCondition match = condition.getMatchingCondition(exchange); + + assertNotNull(match); + assertEquals("/{foo}.*", match.getPatterns().iterator().next()); + + condition = new PatternsRequestCondition(new String[] {"/{foo}"}, null, null, false, false, null); + match = condition.getMatchingCondition(exchange); + + assertNotNull(match); + assertEquals("/{foo}", match.getPatterns().iterator().next()); + } + + // SPR-8410 + + @Test + public void matchSuffixPatternUsingFileExtensions() throws Exception { + String[] patterns = new String[] {"/jobs/{jobName}"}; + Set extensions = Collections.singleton("json"); + PatternsRequestCondition condition = new PatternsRequestCondition(patterns, null, null, true, false, extensions); + + ServerWebExchange exchange = createExchange("/jobs/my.job"); + PatternsRequestCondition match = condition.getMatchingCondition(exchange); + + assertNotNull(match); + assertEquals("/jobs/{jobName}", match.getPatterns().iterator().next()); + + exchange = createExchange("/jobs/my.job.json"); + match = condition.getMatchingCondition(exchange); + + assertNotNull(match); + assertEquals("/jobs/{jobName}.json", match.getPatterns().iterator().next()); + } + + @Test + public void matchSuffixPatternUsingFileExtensions2() throws Exception { + PatternsRequestCondition condition1 = new PatternsRequestCondition( + new String[] {"/prefix"}, null, null, true, false, Collections.singleton("json")); + + PatternsRequestCondition condition2 = new PatternsRequestCondition( + new String[] {"/suffix"}, null, null, true, false, null); + + PatternsRequestCondition combined = condition1.combine(condition2); + + ServerWebExchange exchange = createExchange("/prefix/suffix.json"); + PatternsRequestCondition match = combined.getMatchingCondition(exchange); + + assertNotNull(match); + } + + @Test + public void matchTrailingSlash() throws Exception { + ServerWebExchange exchange = createExchange("/foo/"); + + PatternsRequestCondition condition = new PatternsRequestCondition("/foo"); + PatternsRequestCondition match = condition.getMatchingCondition(exchange); + + assertNotNull(match); + assertEquals("Should match by default", "/foo/", match.getPatterns().iterator().next()); + + condition = new PatternsRequestCondition(new String[] {"/foo"}, null, null, false, true, null); + match = condition.getMatchingCondition(exchange); + + assertNotNull(match); + assertEquals("Trailing slash should be insensitive to useSuffixPatternMatch settings (SPR-6164, SPR-5636)", + "/foo/", match.getPatterns().iterator().next()); + + condition = new PatternsRequestCondition(new String[] {"/foo"}, null, null, false, false, null); + match = condition.getMatchingCondition(exchange); + + assertNull(match); + } + + @Test + public void matchPatternContainsExtension() throws Exception { + PatternsRequestCondition condition = new PatternsRequestCondition("/foo.jpg"); + PatternsRequestCondition match = condition.getMatchingCondition(createExchange("/foo.html")); + + assertNull(match); + } + + @Test + public void compareEqualPatterns() throws Exception { + PatternsRequestCondition c1 = new PatternsRequestCondition("/foo*"); + PatternsRequestCondition c2 = new PatternsRequestCondition("/foo*"); + + assertEquals(0, c1.compareTo(c2, createExchange("/foo"))); + } + + @Test + public void comparePatternSpecificity() throws Exception { + PatternsRequestCondition c1 = new PatternsRequestCondition("/fo*"); + PatternsRequestCondition c2 = new PatternsRequestCondition("/foo"); + + assertEquals(1, c1.compareTo(c2, createExchange("/foo"))); + } + + @Test + public void compareNumberOfMatchingPatterns() throws Exception { + ServerWebExchange exchange = createExchange("/foo.html"); + + PatternsRequestCondition c1 = new PatternsRequestCondition("/foo", "*.jpeg"); + PatternsRequestCondition c2 = new PatternsRequestCondition("/foo", "*.html"); + + PatternsRequestCondition match1 = c1.getMatchingCondition(exchange); + PatternsRequestCondition match2 = c2.getMatchingCondition(exchange); + + assertNotNull(match1); + assertEquals(1, match1.compareTo(match2, exchange)); + } + + + private ServerWebExchange createExchange(String path) throws URISyntaxException { + ServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, new URI(path)); + WebSessionManager sessionManager = new MockWebSessionManager(); + return new DefaultServerWebExchange(request, new MockServerHttpResponse(), sessionManager); + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/condition/ProducesRequestConditionTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/condition/ProducesRequestConditionTests.java new file mode 100644 index 0000000000..fad97ddad7 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/condition/ProducesRequestConditionTests.java @@ -0,0 +1,327 @@ +/* + * Copyright 2002-2016 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.reactive.result.condition; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Collection; +import java.util.Collections; + +import org.junit.Test; + +import org.springframework.http.HttpMethod; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; +import org.springframework.web.server.session.WebSessionManager; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * Unit tests for {@link ProducesRequestCondition}. + * + * @author Rossen Stoyanchev + */ +public class ProducesRequestConditionTests { + + @Test + public void match() throws Exception { + ServerWebExchange exchange = createExchange("text/plain"); + ProducesRequestCondition condition = new ProducesRequestCondition("text/plain"); + + assertNotNull(condition.getMatchingCondition(exchange)); + } + + @Test + public void matchNegated() throws Exception { + ServerWebExchange exchange = createExchange("text/plain"); + ProducesRequestCondition condition = new ProducesRequestCondition("!text/plain"); + + assertNull(condition.getMatchingCondition(exchange)); + } + + @Test + public void getProducibleMediaTypes() throws Exception { + ProducesRequestCondition condition = new ProducesRequestCondition("!application/xml"); + assertEquals(Collections.emptySet(), condition.getProducibleMediaTypes()); + } + + @Test + public void matchWildcard() throws Exception { + ServerWebExchange exchange = createExchange("text/plain"); + ProducesRequestCondition condition = new ProducesRequestCondition("text/*"); + + assertNotNull(condition.getMatchingCondition(exchange)); + } + + @Test + public void matchMultiple() throws Exception { + ServerWebExchange exchange = createExchange("text/plain"); + ProducesRequestCondition condition = new ProducesRequestCondition("text/plain", "application/xml"); + + assertNotNull(condition.getMatchingCondition(exchange)); + } + + @Test + public void matchSingle() throws Exception { + ServerWebExchange exchange = createExchange("application/xml"); + ProducesRequestCondition condition = new ProducesRequestCondition("text/plain"); + + assertNull(condition.getMatchingCondition(exchange)); + } + + @Test + public void matchParseError() throws Exception { + ServerWebExchange exchange = createExchange("bogus"); + ProducesRequestCondition condition = new ProducesRequestCondition("text/plain"); + + assertNull(condition.getMatchingCondition(exchange)); + } + + @Test + public void matchParseErrorWithNegation() throws Exception { + ServerWebExchange exchange = createExchange("bogus"); + ProducesRequestCondition condition = new ProducesRequestCondition("!text/plain"); + + assertNull(condition.getMatchingCondition(exchange)); + } + + @Test + public void compareTo() throws Exception { + ProducesRequestCondition html = new ProducesRequestCondition("text/html"); + ProducesRequestCondition xml = new ProducesRequestCondition("application/xml"); + ProducesRequestCondition none = new ProducesRequestCondition(); + + ServerWebExchange exchange = createExchange("application/xml, text/html"); + + assertTrue(html.compareTo(xml, exchange) > 0); + assertTrue(xml.compareTo(html, exchange) < 0); + assertTrue(xml.compareTo(none, exchange) < 0); + assertTrue(none.compareTo(xml, exchange) > 0); + assertTrue(html.compareTo(none, exchange) < 0); + assertTrue(none.compareTo(html, exchange) > 0); + + exchange = createExchange("application/xml, text/*"); + + assertTrue(html.compareTo(xml, exchange) > 0); + assertTrue(xml.compareTo(html, exchange) < 0); + + exchange = createExchange("application/pdf"); + + assertTrue(html.compareTo(xml, exchange) == 0); + assertTrue(xml.compareTo(html, exchange) == 0); + + // See SPR-7000 + exchange = createExchange("text/html;q=0.9,application/xml"); + + assertTrue(html.compareTo(xml, exchange) > 0); + assertTrue(xml.compareTo(html, exchange) < 0); + } + + @Test + public void compareToWithSingleExpression() throws Exception { + ServerWebExchange exchange = createExchange("text/plain"); + + ProducesRequestCondition condition1 = new ProducesRequestCondition("text/plain"); + ProducesRequestCondition condition2 = new ProducesRequestCondition("text/*"); + + int result = condition1.compareTo(condition2, exchange); + assertTrue("Invalid comparison result: " + result, result < 0); + + result = condition2.compareTo(condition1, exchange); + assertTrue("Invalid comparison result: " + result, result > 0); + } + + @Test + public void compareToMultipleExpressions() throws Exception { + ProducesRequestCondition condition1 = new ProducesRequestCondition("*/*", "text/plain"); + ProducesRequestCondition condition2 = new ProducesRequestCondition("text/*", "text/plain;q=0.7"); + + ServerWebExchange exchange = createExchange("text/plain"); + + int result = condition1.compareTo(condition2, exchange); + assertTrue("Invalid comparison result: " + result, result < 0); + + result = condition2.compareTo(condition1, exchange); + assertTrue("Invalid comparison result: " + result, result > 0); + } + + @Test + public void compareToMultipleExpressionsAndMultipeAcceptHeaderValues() throws Exception { + ProducesRequestCondition condition1 = new ProducesRequestCondition("text/*", "text/plain"); + ProducesRequestCondition condition2 = new ProducesRequestCondition("application/*", "application/xml"); + + ServerWebExchange exchange = createExchange("text/plain", "application/xml"); + + int result = condition1.compareTo(condition2, exchange); + assertTrue("Invalid comparison result: " + result, result < 0); + + result = condition2.compareTo(condition1, exchange); + assertTrue("Invalid comparison result: " + result, result > 0); + + exchange = createExchange("application/xml", "text/plain"); + + result = condition1.compareTo(condition2, exchange); + assertTrue("Invalid comparison result: " + result, result > 0); + + result = condition2.compareTo(condition1, exchange); + assertTrue("Invalid comparison result: " + result, result < 0); + } + + // SPR-8536 + + @Test + public void compareToMediaTypeAll() throws Exception { + ServerWebExchange exchange = createExchange(); + + ProducesRequestCondition condition1 = new ProducesRequestCondition(); + ProducesRequestCondition condition2 = new ProducesRequestCondition("application/json"); + + assertTrue("Should have picked '*/*' condition as an exact match", + condition1.compareTo(condition2, exchange) < 0); + assertTrue("Should have picked '*/*' condition as an exact match", + condition2.compareTo(condition1, exchange) > 0); + + condition1 = new ProducesRequestCondition("*/*"); + condition2 = new ProducesRequestCondition("application/json"); + + assertTrue(condition1.compareTo(condition2, exchange) < 0); + assertTrue(condition2.compareTo(condition1, exchange) > 0); + + exchange = createExchange("*/*"); + + condition1 = new ProducesRequestCondition(); + condition2 = new ProducesRequestCondition("application/json"); + + assertTrue(condition1.compareTo(condition2, exchange) < 0); + assertTrue(condition2.compareTo(condition1, exchange) > 0); + + condition1 = new ProducesRequestCondition("*/*"); + condition2 = new ProducesRequestCondition("application/json"); + + assertTrue(condition1.compareTo(condition2, exchange) < 0); + assertTrue(condition2.compareTo(condition1, exchange) > 0); + } + + // SPR-9021 + + @Test + public void compareToMediaTypeAllWithParameter() throws Exception { + ServerWebExchange exchange = createExchange("*/*;q=0.9"); + + ProducesRequestCondition condition1 = new ProducesRequestCondition(); + ProducesRequestCondition condition2 = new ProducesRequestCondition("application/json"); + + assertTrue(condition1.compareTo(condition2, exchange) < 0); + assertTrue(condition2.compareTo(condition1, exchange) > 0); + } + + @Test + public void compareToEqualMatch() throws Exception { + ServerWebExchange exchange = createExchange("text/*"); + + ProducesRequestCondition condition1 = new ProducesRequestCondition("text/plain"); + ProducesRequestCondition condition2 = new ProducesRequestCondition("text/xhtml"); + + int result = condition1.compareTo(condition2, exchange); + assertTrue("Should have used MediaType.equals(Object) to break the match", result < 0); + + result = condition2.compareTo(condition1, exchange); + assertTrue("Should have used MediaType.equals(Object) to break the match", result > 0); + } + + @Test + public void combine() throws Exception { + ProducesRequestCondition condition1 = new ProducesRequestCondition("text/plain"); + ProducesRequestCondition condition2 = new ProducesRequestCondition("application/xml"); + + ProducesRequestCondition result = condition1.combine(condition2); + assertEquals(condition2, result); + } + + @Test + public void combineWithDefault() throws Exception { + ProducesRequestCondition condition1 = new ProducesRequestCondition("text/plain"); + ProducesRequestCondition condition2 = new ProducesRequestCondition(); + + ProducesRequestCondition result = condition1.combine(condition2); + assertEquals(condition1, result); + } + + @Test + public void instantiateWithProducesAndHeaderConditions() throws Exception { + String[] produces = new String[] {"text/plain"}; + String[] headers = new String[]{"foo=bar", "accept=application/xml,application/pdf"}; + ProducesRequestCondition condition = new ProducesRequestCondition(produces, headers); + + assertConditions(condition, "text/plain", "application/xml", "application/pdf"); + } + + @Test + public void getMatchingCondition() throws Exception { + ServerWebExchange exchange = createExchange("text/plain"); + + ProducesRequestCondition condition = new ProducesRequestCondition("text/plain", "application/xml"); + + ProducesRequestCondition result = condition.getMatchingCondition(exchange); + assertConditions(result, "text/plain"); + + condition = new ProducesRequestCondition("application/xml"); + + result = condition.getMatchingCondition(exchange); + assertNull(result); + } + + private void assertConditions(ProducesRequestCondition condition, String... expected) { + Collection expressions = condition.getContent(); + assertEquals("Invalid number of conditions", expressions.size(), expected.length); + for (String s : expected) { + boolean found = false; + for (ProducesRequestCondition.ProduceMediaTypeExpression expr : expressions) { + String conditionMediaType = expr.getMediaType().toString(); + if (conditionMediaType.equals(s)) { + found = true; + break; + + } + } + if (!found) { + fail("Condition [" + s + "] not found"); + } + } + } + + + private ServerWebExchange createExchange(String... accept) throws URISyntaxException { + ServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, new URI("/")); + if (accept != null) { + for (String value : accept) { + request.getHeaders().add("Accept", value); + } + } + WebSessionManager sessionManager = new MockWebSessionManager(); + return new DefaultServerWebExchange(request, new MockServerHttpResponse(), sessionManager); + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/condition/RequestConditionHolderTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/condition/RequestConditionHolderTests.java new file mode 100644 index 0000000000..266f80aa54 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/condition/RequestConditionHolderTests.java @@ -0,0 +1,141 @@ +/* + * Copyright 2002-2016 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.reactive.result.condition; + +import java.net.URI; +import java.net.URISyntaxException; + +import org.junit.Before; +import org.junit.Test; + +import org.springframework.http.HttpMethod; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; +import org.springframework.web.server.session.WebSessionManager; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; + +/** + * Unit tests for {@link RequestConditionHolder}. + * + * @author Rossen Stoyanchev + */ +public class RequestConditionHolderTests { + + private ServerWebExchange exchange; + + + @Before + public void setUp() throws Exception { + this.exchange = createExchange(); + } + + private ServerWebExchange createExchange() throws URISyntaxException { + ServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, new URI("/")); + WebSessionManager sessionManager = new MockWebSessionManager(); + return new DefaultServerWebExchange(request, new MockServerHttpResponse(), sessionManager); + } + + + @Test + public void combine() { + RequestConditionHolder params1 = new RequestConditionHolder(new ParamsRequestCondition("name1")); + RequestConditionHolder params2 = new RequestConditionHolder(new ParamsRequestCondition("name2")); + RequestConditionHolder expected = new RequestConditionHolder(new ParamsRequestCondition("name1", "name2")); + + assertEquals(expected, params1.combine(params2)); + } + + @Test + public void combineEmpty() { + RequestConditionHolder empty = new RequestConditionHolder(null); + RequestConditionHolder notEmpty = new RequestConditionHolder(new ParamsRequestCondition("name")); + + assertSame(empty, empty.combine(empty)); + assertSame(notEmpty, notEmpty.combine(empty)); + assertSame(notEmpty, empty.combine(notEmpty)); + } + + @Test(expected=ClassCastException.class) + public void combineIncompatible() { + RequestConditionHolder params = new RequestConditionHolder(new ParamsRequestCondition("name")); + RequestConditionHolder headers = new RequestConditionHolder(new HeadersRequestCondition("name")); + params.combine(headers); + } + + @Test + public void match() { + RequestMethodsRequestCondition rm = new RequestMethodsRequestCondition(RequestMethod.GET, RequestMethod.POST); + RequestConditionHolder custom = new RequestConditionHolder(rm); + RequestMethodsRequestCondition expected = new RequestMethodsRequestCondition(RequestMethod.GET); + + RequestConditionHolder holder = custom.getMatchingCondition(this.exchange); + assertNotNull(holder); + assertEquals(expected, holder.getCondition()); + } + + @Test + public void noMatch() { + RequestMethodsRequestCondition rm = new RequestMethodsRequestCondition(RequestMethod.POST); + RequestConditionHolder custom = new RequestConditionHolder(rm); + + assertNull(custom.getMatchingCondition(this.exchange)); + } + + @Test + public void matchEmpty() { + RequestConditionHolder empty = new RequestConditionHolder(null); + assertSame(empty, empty.getMatchingCondition(this.exchange)); + } + + @Test + public void compare() { + RequestConditionHolder params11 = new RequestConditionHolder(new ParamsRequestCondition("1")); + RequestConditionHolder params12 = new RequestConditionHolder(new ParamsRequestCondition("1", "2")); + + assertEquals(1, params11.compareTo(params12, this.exchange)); + assertEquals(-1, params12.compareTo(params11, this.exchange)); + } + + @Test + public void compareEmpty() { + RequestConditionHolder empty = new RequestConditionHolder(null); + RequestConditionHolder empty2 = new RequestConditionHolder(null); + RequestConditionHolder notEmpty = new RequestConditionHolder(new ParamsRequestCondition("name")); + + assertEquals(0, empty.compareTo(empty2, this.exchange)); + assertEquals(-1, notEmpty.compareTo(empty, this.exchange)); + assertEquals(1, empty.compareTo(notEmpty, this.exchange)); + } + + @Test(expected=ClassCastException.class) + public void compareIncompatible() { + RequestConditionHolder params = new RequestConditionHolder(new ParamsRequestCondition("name")); + RequestConditionHolder headers = new RequestConditionHolder(new HeadersRequestCondition("name")); + params.compareTo(headers, this.exchange); + } + + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/condition/RequestMappingInfoTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/condition/RequestMappingInfoTests.java new file mode 100644 index 0000000000..cfb335ca80 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/condition/RequestMappingInfoTests.java @@ -0,0 +1,355 @@ +/* + * Copyright 2002-2016 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.reactive.result.condition; + +import java.net.URI; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.reactive.result.method.RequestMappingInfo; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; +import org.springframework.web.server.session.WebSessionManager; + +import static java.util.Arrays.asList; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +/** + * Unit tests for {@link RequestMappingInfo}. + * + * @author Rossen Stoyanchev + */ +public class RequestMappingInfoTests { + + private ServerWebExchange exchange; + + private ServerHttpRequest request; + + + // TODO: CORS pre-flight (see @Ignored) + + @Before + public void setUp() throws Exception { + WebSessionManager sessionManager = new MockWebSessionManager(); + this.request = new MockServerHttpRequest(HttpMethod.GET, new URI("/foo")); + this.exchange = new DefaultServerWebExchange(request, new MockServerHttpResponse(), sessionManager); + } + + + @Test + public void createEmpty() { + RequestMappingInfo info = new RequestMappingInfo(null, null, null, null, null, null, null); + + assertEquals(0, info.getPatternsCondition().getPatterns().size()); + assertEquals(0, info.getMethodsCondition().getMethods().size()); + assertEquals(true, info.getConsumesCondition().isEmpty()); + assertEquals(true, info.getProducesCondition().isEmpty()); + assertNotNull(info.getParamsCondition()); + assertNotNull(info.getHeadersCondition()); + assertNull(info.getCustomCondition()); + } + + @Test + public void matchPatternsCondition() { + RequestMappingInfo info = new RequestMappingInfo( + new PatternsRequestCondition("/foo*", "/bar"), null, null, null, null, null, null); + RequestMappingInfo expected = new RequestMappingInfo( + new PatternsRequestCondition("/foo*"), null, null, null, null, null, null); + + assertEquals(expected, info.getMatchingCondition(this.exchange)); + + info = new RequestMappingInfo( + new PatternsRequestCondition("/**", "/foo*", "/foo"), null, null, null, null, null, null); + expected = new RequestMappingInfo( + new PatternsRequestCondition("/foo", "/foo*", "/**"), null, null, null, null, null, null); + + assertEquals(expected, info.getMatchingCondition(this.exchange)); + } + + @Test + public void matchParamsCondition() { + this.request.getQueryParams().add("foo", "bar"); + + RequestMappingInfo info = + new RequestMappingInfo( + new PatternsRequestCondition("/foo"), null, + new ParamsRequestCondition("foo=bar"), null, null, null, null); + RequestMappingInfo match = info.getMatchingCondition(this.exchange); + + assertNotNull(match); + + info = new RequestMappingInfo( + new PatternsRequestCondition("/foo"), null, + new ParamsRequestCondition("foo!=bar"), null, null, null, null); + match = info.getMatchingCondition(this.exchange); + + assertNull(match); + } + + @Test + public void matchHeadersCondition() { + this.request.getHeaders().add("foo", "bar"); + + RequestMappingInfo info = + new RequestMappingInfo( + new PatternsRequestCondition("/foo"), null, null, + new HeadersRequestCondition("foo=bar"), null, null, null); + RequestMappingInfo match = info.getMatchingCondition(this.exchange); + + assertNotNull(match); + + info = new RequestMappingInfo( + new PatternsRequestCondition("/foo"), null, null, + new HeadersRequestCondition("foo!=bar"), null, null, null); + match = info.getMatchingCondition(this.exchange); + + assertNull(match); + } + + @Test + public void matchConsumesCondition() { + this.request.getHeaders().setContentType(MediaType.TEXT_PLAIN); + + RequestMappingInfo info = + new RequestMappingInfo( + new PatternsRequestCondition("/foo"), null, null, null, + new ConsumesRequestCondition("text/plain"), null, null); + RequestMappingInfo match = info.getMatchingCondition(this.exchange); + + assertNotNull(match); + + info = new RequestMappingInfo( + new PatternsRequestCondition("/foo"), null, null, null, + new ConsumesRequestCondition("application/xml"), null, null); + match = info.getMatchingCondition(this.exchange); + + assertNull(match); + } + + @Test + public void matchProducesCondition() { + this.request.getHeaders().setAccept(Collections.singletonList(MediaType.TEXT_PLAIN)); + + RequestMappingInfo info = + new RequestMappingInfo( + new PatternsRequestCondition("/foo"), null, null, null, null, + new ProducesRequestCondition("text/plain"), null); + RequestMappingInfo match = info.getMatchingCondition(this.exchange); + + assertNotNull(match); + + info = new RequestMappingInfo( + new PatternsRequestCondition("/foo"), null, null, null, null, + new ProducesRequestCondition("application/xml"), null); + match = info.getMatchingCondition(this.exchange); + + assertNull(match); + } + + @Test + public void matchCustomCondition() { + this.request.getQueryParams().add("foo", "bar"); + + RequestMappingInfo info = + new RequestMappingInfo( + new PatternsRequestCondition("/foo"), null, null, null, null, null, + new ParamsRequestCondition("foo=bar")); + RequestMappingInfo match = info.getMatchingCondition(this.exchange); + + assertNotNull(match); + + info = new RequestMappingInfo( + new PatternsRequestCondition("/foo"), null, + new ParamsRequestCondition("foo!=bar"), null, null, null, + new ParamsRequestCondition("foo!=bar")); + match = info.getMatchingCondition(this.exchange); + + assertNull(match); + } + + @Test + public void compareTwoHttpMethodsOneParam() { + RequestMappingInfo none = new RequestMappingInfo(null, null, null, null, null, null, null); + RequestMappingInfo oneMethod = + new RequestMappingInfo(null, + new RequestMethodsRequestCondition(RequestMethod.GET), null, null, null, null, null); + RequestMappingInfo oneMethodOneParam = + new RequestMappingInfo(null, + new RequestMethodsRequestCondition(RequestMethod.GET), + new ParamsRequestCondition("foo"), null, null, null, null); + + Comparator comparator = (info, otherInfo) -> info.compareTo(otherInfo, exchange); + + List list = asList(none, oneMethod, oneMethodOneParam); + Collections.shuffle(list); + Collections.sort(list, comparator); + + assertEquals(oneMethodOneParam, list.get(0)); + assertEquals(oneMethod, list.get(1)); + assertEquals(none, list.get(2)); + } + + @Test + public void equals() { + RequestMappingInfo info1 = new RequestMappingInfo( + new PatternsRequestCondition("/foo"), + new RequestMethodsRequestCondition(RequestMethod.GET), + new ParamsRequestCondition("foo=bar"), + new HeadersRequestCondition("foo=bar"), + new ConsumesRequestCondition("text/plain"), + new ProducesRequestCondition("text/plain"), + new ParamsRequestCondition("customFoo=customBar")); + + RequestMappingInfo info2 = new RequestMappingInfo( + new PatternsRequestCondition("/foo"), + new RequestMethodsRequestCondition(RequestMethod.GET), + new ParamsRequestCondition("foo=bar"), + new HeadersRequestCondition("foo=bar"), + new ConsumesRequestCondition("text/plain"), + new ProducesRequestCondition("text/plain"), + new ParamsRequestCondition("customFoo=customBar")); + + assertEquals(info1, info2); + assertEquals(info1.hashCode(), info2.hashCode()); + + info2 = new RequestMappingInfo( + new PatternsRequestCondition("/foo", "/NOOOOOO"), + new RequestMethodsRequestCondition(RequestMethod.GET), + new ParamsRequestCondition("foo=bar"), + new HeadersRequestCondition("foo=bar"), + new ConsumesRequestCondition("text/plain"), + new ProducesRequestCondition("text/plain"), + new ParamsRequestCondition("customFoo=customBar")); + + assertFalse(info1.equals(info2)); + assertNotEquals(info1.hashCode(), info2.hashCode()); + + info2 = new RequestMappingInfo( + new PatternsRequestCondition("/foo"), + new RequestMethodsRequestCondition(RequestMethod.GET, RequestMethod.POST), + new ParamsRequestCondition("foo=bar"), + new HeadersRequestCondition("foo=bar"), + new ConsumesRequestCondition("text/plain"), + new ProducesRequestCondition("text/plain"), + new ParamsRequestCondition("customFoo=customBar")); + + assertFalse(info1.equals(info2)); + assertNotEquals(info1.hashCode(), info2.hashCode()); + + info2 = new RequestMappingInfo( + new PatternsRequestCondition("/foo"), + new RequestMethodsRequestCondition(RequestMethod.GET), + new ParamsRequestCondition("/NOOOOOO"), + new HeadersRequestCondition("foo=bar"), + new ConsumesRequestCondition("text/plain"), + new ProducesRequestCondition("text/plain"), + new ParamsRequestCondition("customFoo=customBar")); + + assertFalse(info1.equals(info2)); + assertNotEquals(info1.hashCode(), info2.hashCode()); + + info2 = new RequestMappingInfo( + new PatternsRequestCondition("/foo"), + new RequestMethodsRequestCondition(RequestMethod.GET), + new ParamsRequestCondition("foo=bar"), + new HeadersRequestCondition("/NOOOOOO"), + new ConsumesRequestCondition("text/plain"), + new ProducesRequestCondition("text/plain"), + new ParamsRequestCondition("customFoo=customBar")); + + assertFalse(info1.equals(info2)); + assertNotEquals(info1.hashCode(), info2.hashCode()); + + info2 = new RequestMappingInfo( + new PatternsRequestCondition("/foo"), + new RequestMethodsRequestCondition(RequestMethod.GET), + new ParamsRequestCondition("foo=bar"), + new HeadersRequestCondition("foo=bar"), + new ConsumesRequestCondition("text/NOOOOOO"), + new ProducesRequestCondition("text/plain"), + new ParamsRequestCondition("customFoo=customBar")); + + assertFalse(info1.equals(info2)); + assertNotEquals(info1.hashCode(), info2.hashCode()); + + info2 = new RequestMappingInfo( + new PatternsRequestCondition("/foo"), + new RequestMethodsRequestCondition(RequestMethod.GET), + new ParamsRequestCondition("foo=bar"), + new HeadersRequestCondition("foo=bar"), + new ConsumesRequestCondition("text/plain"), + new ProducesRequestCondition("text/NOOOOOO"), + new ParamsRequestCondition("customFoo=customBar")); + + assertFalse(info1.equals(info2)); + assertNotEquals(info1.hashCode(), info2.hashCode()); + + info2 = new RequestMappingInfo( + new PatternsRequestCondition("/foo"), + new RequestMethodsRequestCondition(RequestMethod.GET), + new ParamsRequestCondition("foo=bar"), + new HeadersRequestCondition("foo=bar"), + new ConsumesRequestCondition("text/plain"), + new ProducesRequestCondition("text/plain"), + new ParamsRequestCondition("customFoo=NOOOOOO")); + + assertFalse(info1.equals(info2)); + assertNotEquals(info1.hashCode(), info2.hashCode()); + } + + @Test + @Ignore + public void preFlightRequest() throws Exception { + ServerHttpRequest request = new MockServerHttpRequest(HttpMethod.OPTIONS, new URI("/foo")); + request.getHeaders().add(HttpHeaders.ORIGIN, "http://domain.com"); + request.getHeaders().add(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST"); + + WebSessionManager manager = new MockWebSessionManager(); + MockServerHttpResponse response = new MockServerHttpResponse(); + ServerWebExchange exchange = new DefaultServerWebExchange(request, response, manager); + + RequestMappingInfo info = new RequestMappingInfo( + new PatternsRequestCondition("/foo"), new RequestMethodsRequestCondition(RequestMethod.POST), null, + null, null, null, null); + RequestMappingInfo match = info.getMatchingCondition(exchange); + assertNotNull(match); + + info = new RequestMappingInfo( + new PatternsRequestCondition("/foo"), new RequestMethodsRequestCondition(RequestMethod.OPTIONS), null, + null, null, null, null); + match = info.getMatchingCondition(exchange); + assertNull("Pre-flight should match the ACCESS_CONTROL_REQUEST_METHOD", match); + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/condition/RequestMethodsRequestConditionTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/condition/RequestMethodsRequestConditionTests.java new file mode 100644 index 0000000000..73aaa01668 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/condition/RequestMethodsRequestConditionTests.java @@ -0,0 +1,157 @@ +/* + * Copyright 2002-2016 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.reactive.result.condition; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Collections; + +import org.junit.Ignore; +import org.junit.Test; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; +import org.springframework.web.server.session.WebSessionManager; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.springframework.web.bind.annotation.RequestMethod.DELETE; +import static org.springframework.web.bind.annotation.RequestMethod.GET; +import static org.springframework.web.bind.annotation.RequestMethod.HEAD; +import static org.springframework.web.bind.annotation.RequestMethod.OPTIONS; +import static org.springframework.web.bind.annotation.RequestMethod.POST; +import static org.springframework.web.bind.annotation.RequestMethod.PUT; + +/** + * Unit tests for {@link RequestMethodsRequestCondition}. + * + * @author Rossen Stoyanchev + */ +public class RequestMethodsRequestConditionTests { + + // TODO: custom method, CORS pre-flight (see @Ignored) + + @Test + public void getMatchingCondition() throws Exception { + testMatch(new RequestMethodsRequestCondition(GET), GET); + testMatch(new RequestMethodsRequestCondition(GET, POST), GET); + testNoMatch(new RequestMethodsRequestCondition(GET), POST); + } + + @Test + public void getMatchingConditionWithHttpHead() throws Exception { + testMatch(new RequestMethodsRequestCondition(HEAD), HEAD); + testMatch(new RequestMethodsRequestCondition(GET), HEAD); + testNoMatch(new RequestMethodsRequestCondition(POST), HEAD); + } + + @Test + public void getMatchingConditionWithEmptyConditions() throws Exception { + RequestMethodsRequestCondition condition = new RequestMethodsRequestCondition(); + for (RequestMethod method : RequestMethod.values()) { + if (!OPTIONS.equals(method)) { + ServerWebExchange exchange = createExchange(method.name()); + assertNotNull(condition.getMatchingCondition(exchange)); + } + } + testNoMatch(condition, OPTIONS); + } + + @Test + @Ignore + public void getMatchingConditionWithCustomMethod() throws Exception { + ServerWebExchange exchange = createExchange("PROPFIND"); + assertNotNull(new RequestMethodsRequestCondition().getMatchingCondition(exchange)); + assertNull(new RequestMethodsRequestCondition(GET, POST).getMatchingCondition(exchange)); + } + + @Test + @Ignore + public void getMatchingConditionWithCorsPreFlight() throws Exception { + ServerWebExchange exchange = createExchange("OPTIONS"); + exchange.getRequest().getHeaders().add("Origin", "http://example.com"); + exchange.getRequest().getHeaders().add(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "PUT"); + + assertNotNull(new RequestMethodsRequestCondition().getMatchingCondition(exchange)); + assertNotNull(new RequestMethodsRequestCondition(PUT).getMatchingCondition(exchange)); + assertNull(new RequestMethodsRequestCondition(DELETE).getMatchingCondition(exchange)); + } + + @Test + public void compareTo() throws Exception { + RequestMethodsRequestCondition c1 = new RequestMethodsRequestCondition(GET, HEAD); + RequestMethodsRequestCondition c2 = new RequestMethodsRequestCondition(POST); + RequestMethodsRequestCondition c3 = new RequestMethodsRequestCondition(); + + ServerWebExchange exchange = createExchange(); + + int result = c1.compareTo(c2, exchange); + assertTrue("Invalid comparison result: " + result, result < 0); + + result = c2.compareTo(c1, exchange); + assertTrue("Invalid comparison result: " + result, result > 0); + + result = c2.compareTo(c3, exchange); + assertTrue("Invalid comparison result: " + result, result < 0); + + result = c1.compareTo(c1, exchange); + assertEquals("Invalid comparison result ", 0, result); + } + + @Test + public void combine() { + RequestMethodsRequestCondition condition1 = new RequestMethodsRequestCondition(GET); + RequestMethodsRequestCondition condition2 = new RequestMethodsRequestCondition(POST); + + RequestMethodsRequestCondition result = condition1.combine(condition2); + assertEquals(2, result.getContent().size()); + } + + + private void testMatch(RequestMethodsRequestCondition condition, RequestMethod method) throws Exception { + ServerWebExchange exchange = createExchange(method.name()); + RequestMethodsRequestCondition actual = condition.getMatchingCondition(exchange); + assertNotNull(actual); + assertEquals(Collections.singleton(method), actual.getContent()); + } + + private void testNoMatch(RequestMethodsRequestCondition condition, RequestMethod method) throws Exception { + ServerWebExchange exchange = createExchange(method.name()); + assertNull(condition.getMatchingCondition(exchange)); + } + + + private ServerWebExchange createExchange() throws URISyntaxException { + return createExchange(null); + } + + private ServerWebExchange createExchange(String method) throws URISyntaxException { + ServerHttpRequest request = new MockServerHttpRequest(HttpMethod.resolve(method), new URI("/")); + WebSessionManager sessionManager = new MockWebSessionManager(); + return new DefaultServerWebExchange(request, new MockServerHttpResponse(), sessionManager); + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/HandlerMethodMappingTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/HandlerMethodMappingTests.java new file mode 100644 index 0000000000..cb2165e400 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/HandlerMethodMappingTests.java @@ -0,0 +1,205 @@ +/* + * Copyright 2002-2016 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.reactive.result.method; + +import java.lang.reflect.Method; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Set; + +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Mono; +import reactor.core.test.TestSubscriber; + +import org.springframework.http.HttpMethod; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.stereotype.Controller; +import org.springframework.util.AntPathMatcher; +import org.springframework.util.PathMatcher; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; +import org.springframework.web.server.session.WebSessionManager; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +/** + * Unit tests for {@link AbstractHandlerMethodMapping}. + * @author Rossen Stoyanchev + */ +public class HandlerMethodMappingTests { + + private AbstractHandlerMethodMapping mapping; + + private MyHandler handler; + + private Method method1; + + private Method method2; + + + @Before + public void setUp() throws Exception { + this.mapping = new MyHandlerMethodMapping(); + this.handler = new MyHandler(); + this.method1 = handler.getClass().getMethod("handlerMethod1"); + this.method2 = handler.getClass().getMethod("handlerMethod2"); + } + + + @Test(expected = IllegalStateException.class) + public void registerDuplicates() { + this.mapping.registerMapping("foo", this.handler, this.method1); + this.mapping.registerMapping("foo", this.handler, this.method2); + } + + @Test + public void directMatch() throws Exception { + String key = "foo"; + this.mapping.registerMapping(key, this.handler, this.method1); + Mono result = this.mapping.getHandler(createExchange(HttpMethod.GET, key)); + + assertEquals(this.method1, ((HandlerMethod) result.block()).getMethod()); + } + + @Test + public void patternMatch() throws Exception { + this.mapping.registerMapping("/fo*", this.handler, this.method1); + this.mapping.registerMapping("/f*", this.handler, this.method2); + + Mono result = this.mapping.getHandler(createExchange(HttpMethod.GET, "/foo")); + assertEquals(this.method1, ((HandlerMethod) result.block()).getMethod()); + } + + @Test + public void ambiguousMatch() throws Exception { + this.mapping.registerMapping("/f?o", this.handler, this.method1); + this.mapping.registerMapping("/fo?", this.handler, this.method2); + Mono result = this.mapping.getHandler(createExchange(HttpMethod.GET, "/foo")); + + TestSubscriber.subscribe(result).assertError(IllegalStateException.class); + } + + @Test + public void registerMapping() throws Exception { + String key1 = "/foo"; + String key2 = "/foo*"; + this.mapping.registerMapping(key1, this.handler, this.method1); + this.mapping.registerMapping(key2, this.handler, this.method2); + + List directUrlMatches = this.mapping.getMappingRegistry().getMappingsByUrl(key1); + + assertNotNull(directUrlMatches); + assertEquals(1, directUrlMatches.size()); + assertEquals(key1, directUrlMatches.get(0)); + } + + @Test + public void registerMappingWithSameMethodAndTwoHandlerInstances() throws Exception { + String key1 = "foo"; + String key2 = "bar"; + MyHandler handler1 = new MyHandler(); + MyHandler handler2 = new MyHandler(); + this.mapping.registerMapping(key1, handler1, this.method1); + this.mapping.registerMapping(key2, handler2, this.method1); + + List directUrlMatches = this.mapping.getMappingRegistry().getMappingsByUrl(key1); + + assertNotNull(directUrlMatches); + assertEquals(1, directUrlMatches.size()); + assertEquals(key1, directUrlMatches.get(0)); + } + + @Test + public void unregisterMapping() throws Exception { + String key = "foo"; + this.mapping.registerMapping(key, this.handler, this.method1); + Mono result = this.mapping.getHandler(createExchange(HttpMethod.GET, key)); + + assertNotNull(result.block()); + + this.mapping.unregisterMapping(key); + result = this.mapping.getHandler(createExchange(HttpMethod.GET, key)); + + assertNull(result.block()); + assertNull(this.mapping.getMappingRegistry().getMappingsByUrl(key)); + } + + + private ServerWebExchange createExchange(HttpMethod httpMethod, String path) throws URISyntaxException { + ServerHttpRequest request = new MockServerHttpRequest(httpMethod, new URI(path)); + WebSessionManager sessionManager = new MockWebSessionManager(); + return new DefaultServerWebExchange(request, new MockServerHttpResponse(), sessionManager); + } + + + private static class MyHandlerMethodMapping extends AbstractHandlerMethodMapping { + + private PathMatcher pathMatcher = new AntPathMatcher(); + + @Override + protected boolean isHandler(Class beanType) { + return true; + } + + @Override + protected String getMappingForMethod(Method method, Class handlerType) { + String methodName = method.getName(); + return methodName.startsWith("handler") ? methodName : null; + } + + @Override + protected Set getMappingPathPatterns(String key) { + return (this.pathMatcher.isPattern(key) ? Collections.emptySet() : Collections.singleton(key)); + } + + @Override + protected String getMatchingMapping(String pattern, ServerWebExchange exchange) { + String lookupPath = exchange.getRequest().getURI().getPath(); + return (this.pathMatcher.match(pattern, lookupPath) ? pattern : null); + } + + @Override + protected Comparator getMappingComparator(ServerWebExchange exchange) { + String lookupPath = exchange.getRequest().getURI().getPath(); + return this.pathMatcher.getPatternComparator(lookupPath); + } + + } + + @Controller + private static class MyHandler { + + @RequestMapping @SuppressWarnings("unused") + public void handlerMethod1() { + } + + @RequestMapping @SuppressWarnings("unused") + public void handlerMethod2() { + } + } +} \ No newline at end of file diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/InvocableHandlerMethodTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/InvocableHandlerMethodTests.java new file mode 100644 index 0000000000..08a8269369 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/InvocableHandlerMethodTests.java @@ -0,0 +1,187 @@ +/* + * Copyright 2002-2015 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.reactive.result.method; + +import java.net.URI; +import java.util.Collections; +import java.util.Optional; + +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Mono; +import reactor.core.test.TestSubscriber; + +import org.springframework.http.HttpMethod; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.ui.ExtendedModelMap; +import org.springframework.ui.ModelMap; +import org.springframework.web.reactive.HandlerResult; +import org.springframework.web.reactive.result.ResolvableMethod; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link InvocableHandlerMethod}. + * @author Rossen Stoyanchev + */ +@SuppressWarnings("ThrowableResultOfMethodCallIgnored") +public class InvocableHandlerMethodTests { + + private ServerWebExchange exchange; + + private ModelMap model = new ExtendedModelMap(); + + + @Before + public void setUp() throws Exception { + this.exchange = new DefaultServerWebExchange( + new MockServerHttpRequest(HttpMethod.GET, new URI("http://localhost:8080/path")), + new MockServerHttpResponse(), + new MockWebSessionManager()); + } + + + @Test + public void invokeMethodWithNoArguments() throws Exception { + InvocableHandlerMethod hm = handlerMethod("noArgs"); + Mono mono = hm.invokeForRequest(this.exchange, this.model); + + assertHandlerResultValue(mono, "success"); + } + + @Test + public void invokeMethodWithNoValue() throws Exception { + InvocableHandlerMethod hm = handlerMethod("singleArg"); + addResolver(hm, Mono.empty()); + Mono mono = hm.invokeForRequest(this.exchange, this.model); + + assertHandlerResultValue(mono, "success:null"); + } + + @Test + public void invokeMethodWithValue() throws Exception { + InvocableHandlerMethod hm = handlerMethod("singleArg"); + addResolver(hm, Mono.just("value1")); + Mono mono = hm.invokeForRequest(this.exchange, this.model); + + assertHandlerResultValue(mono, "success:value1"); + } + + @Test + public void noMatchingResolver() throws Exception { + InvocableHandlerMethod hm = handlerMethod("singleArg"); + Mono mono = hm.invokeForRequest(this.exchange, this.model); + + TestSubscriber.subscribe(mono) + .assertError(IllegalStateException.class) + .assertErrorMessage("No resolver for argument [0] of type [java.lang.String] " + + "on method [" + hm.getMethod().toGenericString() + "]"); + } + + @Test + public void resolverThrowsException() throws Exception { + InvocableHandlerMethod hm = handlerMethod("singleArg"); + addResolver(hm, Mono.error(new IllegalStateException("boo"))); + Mono mono = hm.invokeForRequest(this.exchange, this.model); + + TestSubscriber.subscribe(mono) + .assertError(IllegalStateException.class) + .assertErrorMessage("Error resolving argument [0] of type [java.lang.String] " + + "on method [" + hm.getMethod().toGenericString() + "]"); + } + + @Test + public void resolverWithErrorSignal() throws Exception { + InvocableHandlerMethod hm = handlerMethod("singleArg"); + addResolver(hm, Mono.error(new IllegalStateException("boo"))); + Mono mono = hm.invokeForRequest(this.exchange, this.model); + + TestSubscriber.subscribe(mono) + .assertError(IllegalStateException.class) + .assertErrorMessage("Error resolving argument [0] of type [java.lang.String] " + + "on method [" + hm.getMethod().toGenericString() + "]"); + } + + @Test + public void illegalArgumentExceptionIsWrappedWithInvocationDetails() throws Exception { + InvocableHandlerMethod hm = handlerMethod("singleArg"); + addResolver(hm, Mono.just(1)); + Mono mono = hm.invokeForRequest(this.exchange, this.model); + + TestSubscriber.subscribe(mono) + .assertError(IllegalStateException.class) + .assertErrorMessage("Failed to invoke controller with resolved arguments: " + + "[0][type=java.lang.Integer][value=1] " + + "on method [" + hm.getMethod().toGenericString() + "]"); + } + + @Test + public void invocationTargetExceptionIsUnwrapped() throws Exception { + InvocableHandlerMethod hm = handlerMethod("exceptionMethod"); + Mono mono = hm.invokeForRequest(this.exchange, this.model); + + TestSubscriber.subscribe(mono) + .assertError(IllegalStateException.class) + .assertErrorMessage("boo"); + } + + + private InvocableHandlerMethod handlerMethod(String name) throws Exception { + TestController controller = new TestController(); + return ResolvableMethod.on(controller).name(name).resolveHandlerMethod(); + } + + private void addResolver(InvocableHandlerMethod handlerMethod, Mono resolvedValue) { + HandlerMethodArgumentResolver resolver = mock(HandlerMethodArgumentResolver.class); + when(resolver.supportsParameter(any())).thenReturn(true); + when(resolver.resolveArgument(any(), any(), any())).thenReturn(resolvedValue); + handlerMethod.setHandlerMethodArgumentResolvers(Collections.singletonList(resolver)); + } + + private void assertHandlerResultValue(Mono mono, String expected) { + TestSubscriber.subscribe(mono).assertValuesWith(result -> { + Optional optional = result.getReturnValue(); + assertTrue(optional.isPresent()); + assertEquals(expected, optional.get()); + }); + } + + + @SuppressWarnings("unused") + private static class TestController { + + public String noArgs() { + return "success"; + } + + public String singleArg(String q) { + return "success:" + q; + } + + public void exceptionMethod() { + throw new IllegalStateException("boo"); + } + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/RequestMappingInfoHandlerMappingTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/RequestMappingInfoHandlerMappingTests.java new file mode 100644 index 0000000000..000a8432d3 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/RequestMappingInfoHandlerMappingTests.java @@ -0,0 +1,545 @@ +/* + * Copyright 2002-2016 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.reactive.result.method; + +import java.lang.reflect.Method; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Consumer; + +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Mono; +import reactor.core.test.TestSubscriber; + +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.stereotype.Controller; +import org.springframework.ui.ExtendedModelMap; +import org.springframework.ui.ModelMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.reactive.HandlerMapping; +import org.springframework.web.reactive.HandlerResult; +import org.springframework.web.reactive.result.ResolvableMethod; +import org.springframework.web.reactive.result.method.RequestMappingInfo.BuilderConfiguration; +import org.springframework.web.server.MethodNotAllowedException; +import org.springframework.web.server.NotAcceptableStatusException; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.ServerWebInputException; +import org.springframework.web.server.UnsupportedMediaTypeStatusException; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; +import org.springframework.web.server.session.WebSessionManager; +import org.springframework.web.util.HttpRequestPathHelper; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; +import static org.springframework.web.bind.annotation.RequestMethod.GET; +import static org.springframework.web.bind.annotation.RequestMethod.HEAD; +import static org.springframework.web.bind.annotation.RequestMethod.OPTIONS; +import static org.springframework.web.reactive.result.method.RequestMappingInfo.paths; + +/** + * Unit tests for {@link RequestMappingInfoHandlerMapping}. + * @author Rossen Stoyanchev + */ +public class RequestMappingInfoHandlerMappingTests { + + private TestRequestMappingInfoHandlerMapping handlerMapping; + + + @Before + public void setUp() throws Exception { + this.handlerMapping = new TestRequestMappingInfoHandlerMapping(); + this.handlerMapping.registerHandler(new TestController()); + } + + + @Test + public void getMappingPathPatterns() throws Exception { + String[] patterns = {"/foo/*", "/foo", "/bar/*", "/bar"}; + RequestMappingInfo info = paths(patterns).build(); + Set actual = this.handlerMapping.getMappingPathPatterns(info); + + assertEquals(new HashSet<>(Arrays.asList(patterns)), actual); + } + + @Test + public void getHandlerDirectMatch() throws Exception { + String[] patterns = new String[] {"/foo"}; + String[] params = new String[] {}; + Method expected = resolveMethod(new TestController(), patterns, null, params); + + ServerWebExchange exchange = createExchange(HttpMethod.GET, "/foo"); + HandlerMethod hm = (HandlerMethod) this.handlerMapping.getHandler(exchange).block(); + + assertEquals(expected, hm.getMethod()); + } + + @Test + public void getHandlerGlobMatch() throws Exception { + String[] patterns = new String[] {"/ba*"}; + RequestMethod[] methods = new RequestMethod[] {GET, HEAD}; + Method expected = resolveMethod(new TestController(), patterns, methods, null); + + ServerWebExchange exchange = createExchange(HttpMethod.GET, "/bar"); + HandlerMethod hm = (HandlerMethod) this.handlerMapping.getHandler(exchange).block(); + + assertEquals(expected, hm.getMethod()); + } + + @Test + public void getHandlerEmptyPathMatch() throws Exception { + String[] patterns = new String[] {""}; + Method expected = resolveMethod(new TestController(), patterns, null, null); + + ServerWebExchange exchange = createExchange(HttpMethod.GET, ""); + HandlerMethod hm = (HandlerMethod) this.handlerMapping.getHandler(exchange).block(); + assertEquals(expected, hm.getMethod()); + + exchange = createExchange(HttpMethod.GET, "/"); + hm = (HandlerMethod) this.handlerMapping.getHandler(exchange).block(); + assertEquals(expected, hm.getMethod()); + } + + @Test + public void getHandlerBestMatch() throws Exception { + String[] patterns = new String[] {"/foo"}; + String[] params = new String[] {"p"}; + Method expected = resolveMethod(new TestController(), patterns, null, params); + + ServerWebExchange exchange = createExchange(HttpMethod.GET, "/foo"); + exchange.getRequest().getQueryParams().add("p", "anything"); + HandlerMethod hm = (HandlerMethod) this.handlerMapping.getHandler(exchange).block(); + + assertEquals(expected, hm.getMethod()); + } + + @Test + public void getHandlerRequestMethodNotAllowed() throws Exception { + ServerWebExchange exchange = createExchange(HttpMethod.POST, "/bar"); + Mono mono = this.handlerMapping.getHandler(exchange); + + assertError(mono, MethodNotAllowedException.class, + ex -> assertEquals(new HashSet<>(Arrays.asList("GET", "HEAD")), ex.getSupportedMethods())); + } + + @Test // SPR-9603 + public void getHandlerRequestMethodMatchFalsePositive() throws Exception { + ServerWebExchange exchange = createExchange(HttpMethod.GET, "/users"); + exchange.getRequest().getHeaders().setAccept(Collections.singletonList(MediaType.APPLICATION_XML)); + this.handlerMapping.registerHandler(new UserController()); + Mono mono = this.handlerMapping.getHandler(exchange); + + TestSubscriber.subscribe(mono).assertError(NotAcceptableStatusException.class); + } + + @Test // SPR-8462 + public void getHandlerMediaTypeNotSupported() throws Exception { + testHttpMediaTypeNotSupportedException("/person/1"); + testHttpMediaTypeNotSupportedException("/person/1/"); + testHttpMediaTypeNotSupportedException("/person/1.json"); + } + + @Test + public void getHandlerTestInvalidContentType() throws Exception { + ServerWebExchange exchange = createExchange(HttpMethod.PUT, "/person/1"); + exchange.getRequest().getHeaders().add("Content-Type", "bogus"); + Mono mono = this.handlerMapping.getHandler(exchange); + + assertError(mono, UnsupportedMediaTypeStatusException.class, + ex -> assertEquals("Request failure [status: 415, " + + "reason: \"Invalid mime type \"bogus\": does not contain '/'\"]", + ex.getMessage())); + } + + @Test // SPR-8462 + public void getHandlerTestMediaTypeNotAcceptable() throws Exception { + testMediaTypeNotAcceptable("/persons"); + testMediaTypeNotAcceptable("/persons/"); + testMediaTypeNotAcceptable("/persons.json"); + } + + @Test // SPR-12854 + public void getHandlerTestRequestParamMismatch() throws Exception { + ServerWebExchange exchange = createExchange(HttpMethod.GET, "/params"); + Mono mono = this.handlerMapping.getHandler(exchange); + assertError(mono, ServerWebInputException.class, ex -> { + assertThat(ex.getReason(), containsString("[foo=bar]")); + assertThat(ex.getReason(), containsString("[bar=baz]")); + }); + } + + @Test + public void getHandlerHttpOptions() throws Exception { + testHttpOptions("/foo", "GET,HEAD"); + testHttpOptions("/person/1", "PUT"); + testHttpOptions("/persons", "GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS"); + testHttpOptions("/something", "PUT,POST"); + } + + @Test + public void getHandlerProducibleMediaTypesAttribute() throws Exception { + ServerWebExchange exchange = createExchange(HttpMethod.GET, "/content"); + exchange.getRequest().getHeaders().setAccept(Collections.singletonList(MediaType.APPLICATION_XML)); + this.handlerMapping.getHandler(exchange).block(); + + String name = HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE; + assertEquals(Collections.singleton(MediaType.APPLICATION_XML), exchange.getAttributes().get(name)); + + exchange = createExchange(HttpMethod.GET, "/content"); + exchange.getRequest().getHeaders().setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + this.handlerMapping.getHandler(exchange).block(); + + assertNull("Negated expression shouldn't be listed as producible type", + exchange.getAttributes().get(name)); + } + + @Test @SuppressWarnings("unchecked") + public void handleMatchUriTemplateVariables() throws Exception { + RequestMappingInfo key = paths("/{path1}/{path2}").build(); + ServerWebExchange exchange = createExchange(HttpMethod.GET, "/1/2"); + String lookupPath = exchange.getRequest().getURI().getPath(); + this.handlerMapping.handleMatch(key, lookupPath, exchange); + + String name = HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE; + Map uriVariables = (Map) exchange.getAttributes().get(name); + + assertNotNull(uriVariables); + assertEquals("1", uriVariables.get("path1")); + assertEquals("2", uriVariables.get("path2")); + } + + @Test // SPR-9098 + public void handleMatchUriTemplateVariablesDecode() throws Exception { + RequestMappingInfo key = paths("/{group}/{identifier}").build(); + ServerWebExchange exchange = createExchange(HttpMethod.GET, "/group/a%2Fb"); + + HttpRequestPathHelper pathHelper = new HttpRequestPathHelper(); + pathHelper.setUrlDecode(false); + String lookupPath = pathHelper.getLookupPathForRequest(exchange); + + this.handlerMapping.setPathHelper(pathHelper); + this.handlerMapping.handleMatch(key, lookupPath, exchange); + + String name = HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE; + @SuppressWarnings("unchecked") + Map uriVariables = (Map) exchange.getAttributes().get(name); + + assertNotNull(uriVariables); + assertEquals("group", uriVariables.get("group")); + assertEquals("a/b", uriVariables.get("identifier")); + } + + @Test + public void handleMatchBestMatchingPatternAttribute() throws Exception { + RequestMappingInfo key = paths("/{path1}/2", "/**").build(); + ServerWebExchange exchange = createExchange(HttpMethod.GET, "/1/2"); + this.handlerMapping.handleMatch(key, "/1/2", exchange); + + assertEquals("/{path1}/2", exchange.getAttributes().get(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE)); + } + + @Test + public void handleMatchBestMatchingPatternAttributeNoPatternsDefined() throws Exception { + RequestMappingInfo key = paths().build(); + ServerWebExchange exchange = createExchange(HttpMethod.GET, "/1/2"); + + this.handlerMapping.handleMatch(key, "/1/2", exchange); + + assertEquals("/1/2", exchange.getAttributes().get(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE)); + } + + @Test + public void handleMatchMatrixVariables() throws Exception { + ServerWebExchange exchange; + MultiValueMap matrixVariables; + Map uriVariables; + + exchange = createExchange(HttpMethod.GET, "/"); + handleMatch(exchange, "/{cars}", "/cars;colors=red,blue,green;year=2012"); + + matrixVariables = getMatrixVariables(exchange, "cars"); + uriVariables = getUriTemplateVariables(exchange); + + assertNotNull(matrixVariables); + assertEquals(Arrays.asList("red", "blue", "green"), matrixVariables.get("colors")); + assertEquals("2012", matrixVariables.getFirst("year")); + assertEquals("cars", uriVariables.get("cars")); + + exchange = createExchange(HttpMethod.GET, "/"); + handleMatch(exchange, "/{cars:[^;]+}{params}", "/cars;colors=red,blue,green;year=2012"); + + matrixVariables = getMatrixVariables(exchange, "params"); + uriVariables = getUriTemplateVariables(exchange); + + assertNotNull(matrixVariables); + assertEquals(Arrays.asList("red", "blue", "green"), matrixVariables.get("colors")); + assertEquals("2012", matrixVariables.getFirst("year")); + assertEquals("cars", uriVariables.get("cars")); + assertEquals(";colors=red,blue,green;year=2012", uriVariables.get("params")); + + exchange = createExchange(HttpMethod.GET, "/"); + handleMatch(exchange, "/{cars:[^;]+}{params}", "/cars"); + + matrixVariables = getMatrixVariables(exchange, "params"); + uriVariables = getUriTemplateVariables(exchange); + + assertNull(matrixVariables); + assertEquals("cars", uriVariables.get("cars")); + assertEquals("", uriVariables.get("params")); + } + + @Test + public void handleMatchMatrixVariablesDecoding() throws Exception { + HttpRequestPathHelper urlPathHelper = new HttpRequestPathHelper(); + urlPathHelper.setUrlDecode(false); + this.handlerMapping.setPathHelper(urlPathHelper); + + ServerWebExchange exchange = createExchange(HttpMethod.GET, "/"); + handleMatch(exchange, "/path{filter}", "/path;mvar=a%2fb"); + + MultiValueMap matrixVariables = getMatrixVariables(exchange, "filter"); + Map uriVariables = getUriTemplateVariables(exchange); + + assertNotNull(matrixVariables); + assertEquals(Collections.singletonList("a/b"), matrixVariables.get("mvar")); + assertEquals(";mvar=a/b", uriVariables.get("filter")); + } + + + private ServerWebExchange createExchange(HttpMethod method, String url) throws URISyntaxException { + ServerHttpRequest request = new MockServerHttpRequest(method, new URI(url)); + WebSessionManager sessionManager = new MockWebSessionManager(); + return new DefaultServerWebExchange(request, new MockServerHttpResponse(), sessionManager); + + } + + @SuppressWarnings("unchecked") + private void assertError(Mono mono, final Class exceptionClass, final Consumer consumer) { + TestSubscriber + .subscribe(mono) + .assertErrorWith(ex -> { + assertEquals(exceptionClass, ex.getClass()); + consumer.accept((T) ex); + }); + } + + + private void testHttpMediaTypeNotSupportedException(String url) throws Exception { + ServerWebExchange exchange = createExchange(HttpMethod.PUT, url); + exchange.getRequest().getHeaders().setContentType(MediaType.APPLICATION_JSON); + Mono mono = this.handlerMapping.getHandler(exchange); + + assertError(mono, UnsupportedMediaTypeStatusException.class, ex -> + assertEquals("Invalid supported consumable media types", + Collections.singletonList(new MediaType("application", "xml")), + ex.getSupportedMediaTypes())); + } + + private void testHttpOptions(String requestURI, String allowHeader) throws Exception { + ServerWebExchange exchange = createExchange(HttpMethod.OPTIONS, requestURI); + HandlerMethod handlerMethod = (HandlerMethod) this.handlerMapping.getHandler(exchange).block(); + + ModelMap model = new ExtendedModelMap(); + Mono mono = new InvocableHandlerMethod(handlerMethod).invokeForRequest(exchange, model); + + HandlerResult result = mono.block(); + assertNotNull(result); + + Optional value = result.getReturnValue(); + assertTrue(value.isPresent()); + assertEquals(HttpHeaders.class, value.get().getClass()); + assertEquals(allowHeader, ((HttpHeaders) value.get()).getFirst("Allow")); + } + + private void testMediaTypeNotAcceptable(String url) throws Exception { + ServerWebExchange exchange = createExchange(HttpMethod.GET, url); + exchange.getRequest().getHeaders().setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + Mono mono = this.handlerMapping.getHandler(exchange); + + assertError(mono, NotAcceptableStatusException.class, ex -> + assertEquals("Invalid supported producible media types", + Collections.singletonList(new MediaType("application", "xml")), + ex.getSupportedMediaTypes())); + } + + private void handleMatch(ServerWebExchange exchange, String pattern, String lookupPath) { + RequestMappingInfo info = paths(pattern).build(); + this.handlerMapping.handleMatch(info, lookupPath, exchange); + } + + @SuppressWarnings("unchecked") + private MultiValueMap getMatrixVariables(ServerWebExchange exchange, String uriVarName) { + String attrName = HandlerMapping.MATRIX_VARIABLES_ATTRIBUTE; + return ((Map>) exchange.getAttributes().get(attrName)).get(uriVarName); + } + + @SuppressWarnings("unchecked") + private Map getUriTemplateVariables(ServerWebExchange exchange) { + String attrName = HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE; + return (Map) exchange.getAttributes().get(attrName); + } + + private Method resolveMethod(Object controller, String[] patterns, + RequestMethod[] methods, String[] params) { + + return ResolvableMethod.on(controller) + .matching(method -> { + RequestMapping annot = AnnotatedElementUtils.findMergedAnnotation(method, RequestMapping.class); + if (annot == null) { + return false; + } + else if (patterns != null && !Arrays.equals(annot.path(), patterns)) { + return false; + } + else if (methods != null && !Arrays.equals(annot.method(), methods)) { + return false; + } + else if (params != null && (!Arrays.equals(annot.params(), params))) { + return false; + } + return true; + }) + .resolve(); + } + + + @SuppressWarnings("unused") + @Controller + private static class TestController { + + @GetMapping("/foo") + public void foo() { + } + + @GetMapping(path = "/foo", params="p") + public void fooParam() { + } + + @RequestMapping(path = "/ba*", method = { GET, HEAD }) + public void bar() { + } + + @RequestMapping(path = "") + public void empty() { + } + + @PutMapping(path = "/person/{id}", consumes="application/xml") + public void consumes(@RequestBody String text) { + } + + @RequestMapping(path = "/persons", produces="application/xml") + public String produces() { + return ""; + } + + @RequestMapping(path = "/params", params="foo=bar") + public String param() { + return ""; + } + + @RequestMapping(path = "/params", params="bar=baz") + public String param2() { + return ""; + } + + @RequestMapping(path = "/content", produces="application/xml") + public String xmlContent() { + return ""; + } + + @RequestMapping(path = "/content", produces="!application/xml") + public String nonXmlContent() { + return ""; + } + + @RequestMapping(path = "/something", method = OPTIONS) + public HttpHeaders fooOptions() { + HttpHeaders headers = new HttpHeaders(); + headers.add("Allow", "PUT,POST"); + return headers; + } + } + + @SuppressWarnings("unused") + @Controller + private static class UserController { + + @GetMapping(path = "/users", produces = "application/json") + public void getUser() { + } + + @PutMapping(path = "/users") + public void saveUser() { + } + } + + private static class TestRequestMappingInfoHandlerMapping extends RequestMappingInfoHandlerMapping { + + void registerHandler(Object handler) { + super.detectHandlerMethods(handler); + } + + @Override + protected boolean isHandler(Class beanType) { + return AnnotationUtils.findAnnotation(beanType, RequestMapping.class) != null; + } + + @Override + protected RequestMappingInfo getMappingForMethod(Method method, Class handlerType) { + RequestMapping annot = AnnotatedElementUtils.findMergedAnnotation(method, RequestMapping.class); + if (annot != null) { + BuilderConfiguration options = new BuilderConfiguration(); + options.setPathHelper(getPathHelper()); + options.setPathMatcher(getPathMatcher()); + options.setSuffixPatternMatch(true); + options.setTrailingSlashMatch(true); + return paths(annot.value()).methods(annot.method()) + .params(annot.params()).headers(annot.headers()) + .consumes(annot.consumes()).produces(annot.produces()) + .options(options).build(); + } + else { + return null; + } + } + } + +} \ No newline at end of file diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/AbstractRequestMappingIntegrationTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/AbstractRequestMappingIntegrationTests.java new file mode 100644 index 0000000000..2beb97217f --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/AbstractRequestMappingIntegrationTests.java @@ -0,0 +1,95 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import java.net.URI; + +import org.springframework.context.ApplicationContext; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.http.server.reactive.AbstractHttpHandlerIntegrationTests; +import org.springframework.http.server.reactive.HttpHandler; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.reactive.DispatcherHandler; +import org.springframework.web.server.adapter.WebHttpHandlerBuilder; + +import static org.springframework.http.RequestEntity.get; + +/** + * + * @author Rossen Stoyanchev + */ +public abstract class AbstractRequestMappingIntegrationTests extends AbstractHttpHandlerIntegrationTests { + + private ApplicationContext applicationContext; + + private RestTemplate restTemplate = new RestTemplate(); + + + @Override + protected HttpHandler createHttpHandler() { + this.applicationContext = initApplicationContext(); + DispatcherHandler handler = new DispatcherHandler(); + handler.setApplicationContext(this.applicationContext); + return WebHttpHandlerBuilder.webHandler(handler).build(); + } + + protected abstract ApplicationContext initApplicationContext(); + + + ApplicationContext getApplicationContext() { + return this.applicationContext; + } + + + ResponseEntity performGet(String url, MediaType out, + Class type) throws Exception { + + return this.restTemplate.exchange(prepareGet(url, out), type); + } + + ResponseEntity performGet(String url, MediaType out, + ParameterizedTypeReference type) throws Exception { + + return this.restTemplate.exchange(prepareGet(url, out), type); + } + + ResponseEntity performPost(String url, MediaType in, Object body, MediaType out, + Class type) throws Exception { + + return this.restTemplate.exchange(preparePost(url, in, body, out), type); + } + + ResponseEntity performPost(String url, MediaType in, Object body, + MediaType out, ParameterizedTypeReference type) throws Exception { + + return this.restTemplate.exchange(preparePost(url, in, body, out), type); + } + + private RequestEntity prepareGet(String url, MediaType accept) throws Exception { + URI uri = new URI("http://localhost:" + this.port + url); + return (accept != null ? get(uri).accept(accept).build() : get(uri).build()); + } + + private RequestEntity preparePost(String url, MediaType in, Object body, MediaType out) throws Exception { + URI uri = new URI("http://localhost:" + this.port + url); + return (out != null ? + RequestEntity.post(uri).contentType(in).accept(out).body(body) : + RequestEntity.post(uri).contentType(in).body(body)); + } +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/CookieValueMethodArgumentResolverTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/CookieValueMethodArgumentResolverTests.java new file mode 100644 index 0000000000..c234b48941 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/CookieValueMethodArgumentResolverTests.java @@ -0,0 +1,132 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import java.lang.reflect.Method; +import java.net.URI; + +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Mono; +import reactor.core.test.TestSubscriber; + +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.core.MethodParameter; +import org.springframework.core.annotation.SynthesizingMethodParameter; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.http.HttpCookie; +import org.springframework.http.HttpMethod; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.ServerWebInputException; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; +import org.springframework.web.server.session.WebSessionManager; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Test fixture with {@link CookieValueMethodArgumentResolver}. + * + * @author Rossen Stoyanchev + */ +public class CookieValueMethodArgumentResolverTests { + + private CookieValueMethodArgumentResolver resolver; + + private ServerWebExchange exchange; + + private MethodParameter cookieParameter; + private MethodParameter cookieStringParameter; + private MethodParameter stringParameter; + + + @Before + public void setUp() throws Exception { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.refresh(); + ConversionService cs = new DefaultConversionService(); + this.resolver = new CookieValueMethodArgumentResolver(cs, context.getBeanFactory()); + + ServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, new URI("/")); + WebSessionManager sessionManager = new MockWebSessionManager(); + this.exchange = new DefaultServerWebExchange(request, new MockServerHttpResponse(), sessionManager); + + Method method = getClass().getMethod("params", HttpCookie.class, String.class, String.class); + this.cookieParameter = new SynthesizingMethodParameter(method, 0); + this.cookieStringParameter = new SynthesizingMethodParameter(method, 1); + this.stringParameter = new SynthesizingMethodParameter(method, 2); + } + + + @Test + public void supportsParameter() { + assertTrue(this.resolver.supportsParameter(this.cookieParameter)); + assertTrue(this.resolver.supportsParameter(this.cookieStringParameter)); + assertFalse(this.resolver.supportsParameter(this.stringParameter)); + } + + @Test + public void resolveCookieArgument() { + HttpCookie expected = new HttpCookie("name", "foo"); + this.exchange.getRequest().getCookies().add(expected.getName(), expected); + + Mono mono = this.resolver.resolveArgument(this.cookieParameter, null, this.exchange); + assertEquals(expected, mono.block()); + } + + @Test + public void resolveCookieStringArgument() { + HttpCookie cookie = new HttpCookie("name", "foo"); + this.exchange.getRequest().getCookies().add(cookie.getName(), cookie); + + Mono mono = this.resolver.resolveArgument(this.cookieStringParameter, null, this.exchange); + assertEquals("Invalid result", cookie.getValue(), mono.block()); + } + + @Test + public void resolveCookieDefaultValue() { + Mono mono = this.resolver.resolveArgument(this.cookieStringParameter, null, this.exchange); + Object result = mono.block(); + + assertTrue(result instanceof String); + assertEquals("bar", result); + } + + @Test + public void notFound() { + Mono mono = resolver.resolveArgument(this.cookieParameter, null, this.exchange); + TestSubscriber + .subscribe(mono) + .assertError(ServerWebInputException.class); + } + + + @SuppressWarnings("unused") + public void params( + @CookieValue("name") HttpCookie cookie, + @CookieValue(name = "name", defaultValue = "bar") String cookieString, + String stringParam) { + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/ExpressionValueMethodArgumentResolverTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/ExpressionValueMethodArgumentResolverTests.java new file mode 100644 index 0000000000..5ad0d7f5e8 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/ExpressionValueMethodArgumentResolverTests.java @@ -0,0 +1,104 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import java.lang.reflect.Method; +import java.net.URI; + +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Mono; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.core.MethodParameter; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.support.GenericConversionService; +import org.springframework.http.HttpMethod; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; +import org.springframework.web.server.session.WebSessionManager; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Unit tests for {@link ExpressionValueMethodArgumentResolver}. + * + * @author Rossen Stoyanchev + */ +public class ExpressionValueMethodArgumentResolverTests { + + private ExpressionValueMethodArgumentResolver resolver; + + private ServerWebExchange exchange; + + private MethodParameter paramSystemProperty; + private MethodParameter paramNotSupported; + + + @Before + public void setUp() throws Exception { + ConversionService conversionService = new GenericConversionService(); + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.refresh(); + this.resolver = new ExpressionValueMethodArgumentResolver(conversionService, context.getBeanFactory()); + + ServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, new URI("/")); + WebSessionManager sessionManager = new MockWebSessionManager(); + this.exchange = new DefaultServerWebExchange(request, new MockServerHttpResponse(), sessionManager); + + Method method = getClass().getMethod("params", int.class, String.class); + this.paramSystemProperty = new MethodParameter(method, 0); + this.paramNotSupported = new MethodParameter(method, 1); + } + + + @Test + public void supportsParameter() throws Exception { + assertTrue(this.resolver.supportsParameter(this.paramSystemProperty)); + assertFalse(this.resolver.supportsParameter(this.paramNotSupported)); + } + + @Test + public void resolveSystemProperty() throws Exception { + System.setProperty("systemProperty", "22"); + try { + Mono mono = this.resolver.resolveArgument(this.paramSystemProperty, null, this.exchange); + Object value = mono.block(); + assertEquals(22, value); + } + finally { + System.clearProperty("systemProperty"); + } + + } + + // TODO: test with expression for ServerWebExchange + + + @SuppressWarnings("unused") + public void params(@Value("#{systemProperties.systemProperty}") int param1, + String notSupported) { + } + +} \ No newline at end of file diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/HttpEntityArgumentResolverTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/HttpEntityArgumentResolverTests.java new file mode 100644 index 0000000000..839960eaed --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/HttpEntityArgumentResolverTests.java @@ -0,0 +1,310 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import org.junit.Before; +import org.junit.Test; +import reactor.core.converter.RxJava1ObservableConverter; +import reactor.core.converter.RxJava1SingleConverter; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.test.TestSubscriber; +import rx.Observable; +import rx.Single; + +import org.springframework.core.MethodParameter; +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.StringDecoder; +import org.springframework.core.convert.support.MonoToCompletableFutureConverter; +import org.springframework.core.convert.support.ReactorToRxJava1Converter; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.format.support.DefaultFormattingConversionService; +import org.springframework.format.support.FormattingConversionService; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; +import org.springframework.http.converter.reactive.CodecHttpMessageConverter; +import org.springframework.http.converter.reactive.HttpMessageConverter; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.ui.ExtendedModelMap; +import org.springframework.web.reactive.result.ResolvableMethod; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.ServerWebInputException; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.springframework.core.ResolvableType.forClassWithGenerics; + +/** + * Unit tests for {@link HttpEntityArgumentResolver}.When adding a test also + * consider whether the logic under test is in a parent class, then see: + * {@link MessageConverterArgumentResolverTests}. + * + * @author Rossen Stoyanchev + */ +public class HttpEntityArgumentResolverTests { + + private HttpEntityArgumentResolver resolver = createResolver(); + + private ServerWebExchange exchange; + + private MockServerHttpRequest request; + + private ResolvableMethod testMethod = ResolvableMethod.onClass(getClass()).name("handle"); + + + @Before + public void setUp() throws Exception { + this.request = new MockServerHttpRequest(HttpMethod.POST, new URI("/path")); + MockServerHttpResponse response = new MockServerHttpResponse(); + this.exchange = new DefaultServerWebExchange(this.request, response, new MockWebSessionManager()); + } + + private HttpEntityArgumentResolver createResolver() { + List> converters = new ArrayList<>(); + converters.add(new CodecHttpMessageConverter<>(new StringDecoder())); + + FormattingConversionService service = new DefaultFormattingConversionService(); + service.addConverter(new MonoToCompletableFutureConverter()); + service.addConverter(new ReactorToRxJava1Converter()); + + return new HttpEntityArgumentResolver(converters, service); + } + + + @Test + public void supports() throws Exception { + testSupports(httpEntityType(String.class)); + testSupports(httpEntityType(forClassWithGenerics(Mono.class, String.class))); + testSupports(httpEntityType(forClassWithGenerics(Single.class, String.class))); + testSupports(httpEntityType(forClassWithGenerics(CompletableFuture.class, String.class))); + testSupports(httpEntityType(forClassWithGenerics(Flux.class, String.class))); + testSupports(httpEntityType(forClassWithGenerics(Observable.class, String.class))); + testSupports(forClassWithGenerics(RequestEntity.class, String.class)); + } + + @Test + public void doesNotSupport() throws Exception { + ResolvableType type = ResolvableType.forClassWithGenerics(Mono.class, String.class); + assertFalse(this.resolver.supportsParameter(this.testMethod.resolveParam(type))); + + type = ResolvableType.forClass(String.class); + assertFalse(this.resolver.supportsParameter(this.testMethod.resolveParam(type))); + } + + @Test + public void emptyBodyWithString() throws Exception { + ResolvableType type = httpEntityType(String.class); + HttpEntity entity = resolveValueWithEmptyBody(type); + + assertNull(entity.getBody()); + } + + @Test + public void emptyBodyWithMono() throws Exception { + ResolvableType type = httpEntityType(forClassWithGenerics(Mono.class, String.class)); + HttpEntity> entity = resolveValueWithEmptyBody(type); + + TestSubscriber.subscribe(entity.getBody()) + .assertNoError() + .assertComplete() + .assertNoValues(); + } + + @Test + public void emptyBodyWithFlux() throws Exception { + ResolvableType type = httpEntityType(forClassWithGenerics(Flux.class, String.class)); + HttpEntity> entity = resolveValueWithEmptyBody(type); + + TestSubscriber.subscribe(entity.getBody()) + .assertNoError() + .assertComplete() + .assertNoValues(); + } + + @Test + public void emptyBodyWithSingle() throws Exception { + ResolvableType type = httpEntityType(forClassWithGenerics(Single.class, String.class)); + HttpEntity> entity = resolveValueWithEmptyBody(type); + + TestSubscriber.subscribe(RxJava1SingleConverter.toPublisher(entity.getBody())) + .assertNoValues() + .assertError(ServerWebInputException.class); + } + + @Test + public void emptyBodyWithObservable() throws Exception { + ResolvableType type = httpEntityType(forClassWithGenerics(Observable.class, String.class)); + HttpEntity> entity = resolveValueWithEmptyBody(type); + + TestSubscriber.subscribe(RxJava1ObservableConverter.toPublisher(entity.getBody())) + .assertNoError() + .assertComplete() + .assertNoValues(); + } + + @Test + public void emptyBodyWithCompletableFuture() throws Exception { + ResolvableType type = httpEntityType(forClassWithGenerics(CompletableFuture.class, String.class)); + HttpEntity> entity = resolveValueWithEmptyBody(type); + + entity.getBody().whenComplete((body, ex) -> { + assertNull(body); + assertNull(ex); + }); + } + + @Test + public void httpEntityWithStringBody() throws Exception { + String body = "line1"; + ResolvableType type = httpEntityType(String.class); + HttpEntity httpEntity = resolveValue(type, body); + + assertEquals(this.request.getHeaders(), httpEntity.getHeaders()); + assertEquals("line1", httpEntity.getBody()); + } + + @Test + public void httpEntityWithMonoBody() throws Exception { + String body = "line1"; + ResolvableType type = httpEntityType(forClassWithGenerics(Mono.class, String.class)); + HttpEntity> httpEntity = resolveValue(type, body); + + assertEquals(this.request.getHeaders(), httpEntity.getHeaders()); + assertEquals("line1", httpEntity.getBody().block()); + } + + @Test + public void httpEntityWithSingleBody() throws Exception { + String body = "line1"; + ResolvableType type = httpEntityType(forClassWithGenerics(Single.class, String.class)); + HttpEntity> httpEntity = resolveValue(type, body); + + assertEquals(this.request.getHeaders(), httpEntity.getHeaders()); + assertEquals("line1", httpEntity.getBody().toBlocking().value()); + } + + @Test + public void httpEntityWithCompletableFutureBody() throws Exception { + String body = "line1"; + ResolvableType type = httpEntityType(forClassWithGenerics(CompletableFuture.class, String.class)); + HttpEntity> httpEntity = resolveValue(type, body); + + assertEquals(this.request.getHeaders(), httpEntity.getHeaders()); + assertEquals("line1", httpEntity.getBody().get()); + } + + @Test + public void httpEntityWithFluxBody() throws Exception { + String body = "line1\nline2\nline3\n"; + ResolvableType type = httpEntityType(forClassWithGenerics(Flux.class, String.class)); + HttpEntity> httpEntity = resolveValue(type, body); + + assertEquals(this.request.getHeaders(), httpEntity.getHeaders()); + TestSubscriber.subscribe(httpEntity.getBody()).assertValues("line1\n", "line2\n", "line3\n"); + } + + @Test + public void requestEntity() throws Exception { + String body = "line1"; + ResolvableType type = forClassWithGenerics(RequestEntity.class, String.class); + RequestEntity requestEntity = resolveValue(type, body); + + assertEquals(this.request.getMethod(), requestEntity.getMethod()); + assertEquals(this.request.getURI(), requestEntity.getUrl()); + assertEquals(this.request.getHeaders(), requestEntity.getHeaders()); + assertEquals("line1", requestEntity.getBody()); + } + + + private ResolvableType httpEntityType(Class bodyType) { + return httpEntityType(ResolvableType.forClass(bodyType)); + } + + private ResolvableType httpEntityType(ResolvableType type) { + return forClassWithGenerics(HttpEntity.class, type); + } + + private void testSupports(ResolvableType type) { + MethodParameter parameter = this.testMethod.resolveParam(type); + assertTrue(this.resolver.supportsParameter(parameter)); + } + + @SuppressWarnings("unchecked") + private T resolveValue(ResolvableType type, String body) { + + this.request.getHeaders().add("foo", "bar"); + this.request.getHeaders().setContentType(MediaType.TEXT_PLAIN); + this.request.writeWith(Flux.just(dataBuffer(body))); + + MethodParameter param = this.testMethod.resolveParam(type); + Mono result = this.resolver.resolveArgument(param, new ExtendedModelMap(), this.exchange); + Object value = result.block(Duration.ofSeconds(5)); + + assertNotNull(value); + assertTrue("Unexpected return value type: " + value.getClass(), + param.getParameterType().isAssignableFrom(value.getClass())); + + return (T) value; + } + + @SuppressWarnings("unchecked") + private HttpEntity resolveValueWithEmptyBody(ResolvableType type) { + this.request.writeWith(Flux.empty()); + MethodParameter param = this.testMethod.resolveParam(type); + Mono result = this.resolver.resolveArgument(param, new ExtendedModelMap(), this.exchange); + HttpEntity httpEntity = (HttpEntity) result.block(Duration.ofSeconds(5)); + + assertEquals(this.request.getHeaders(), httpEntity.getHeaders()); + return (HttpEntity) httpEntity; + } + + private DataBuffer dataBuffer(String body) { + byte[] bytes = body.getBytes(Charset.forName("UTF-8")); + ByteBuffer byteBuffer = ByteBuffer.wrap(bytes); + return new DefaultDataBufferFactory().wrap(byteBuffer); + } + + + @SuppressWarnings("unused") + void handle( + String string, + Mono monoString, + HttpEntity httpEntity, + HttpEntity> monoBody, + HttpEntity> fluxBody, + HttpEntity> singleBody, + HttpEntity> observableBody, + HttpEntity> completableFutureBody, + RequestEntity requestEntity) {} + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/MessageConverterArgumentResolverTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/MessageConverterArgumentResolverTests.java new file mode 100644 index 0000000000..a2590f8a08 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/MessageConverterArgumentResolverTests.java @@ -0,0 +1,426 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import java.io.Serializable; +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import javax.xml.bind.annotation.XmlRootElement; + +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.test.TestSubscriber; +import rx.Observable; +import rx.Single; + +import org.springframework.core.MethodParameter; +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.Decoder; +import org.springframework.core.convert.support.MonoToCompletableFutureConverter; +import org.springframework.core.convert.support.ReactorToRxJava1Converter; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.format.support.DefaultFormattingConversionService; +import org.springframework.format.support.FormattingConversionService; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.codec.json.JacksonJsonDecoder; +import org.springframework.http.converter.reactive.CodecHttpMessageConverter; +import org.springframework.http.converter.reactive.HttpMessageConverter; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.validation.Errors; +import org.springframework.validation.Validator; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.reactive.result.ResolvableMethod; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.ServerWebInputException; +import org.springframework.web.server.UnsupportedMediaTypeStatusException; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.springframework.core.ResolvableType.forClass; +import static org.springframework.core.ResolvableType.forClassWithGenerics; + +/** + * Unit tests for {@link AbstractMessageConverterArgumentResolver}. + * @author Rossen Stoyanchev + */ +public class MessageConverterArgumentResolverTests { + + private AbstractMessageConverterArgumentResolver resolver = resolver(new JacksonJsonDecoder()); + + private ServerWebExchange exchange; + + private MockServerHttpRequest request; + + private ResolvableMethod testMethod = ResolvableMethod.onClass(this.getClass()).name("handle"); + + + @Before + public void setUp() throws Exception { + this.request = new MockServerHttpRequest(HttpMethod.POST, new URI("/path")); + MockServerHttpResponse response = new MockServerHttpResponse(); + this.exchange = new DefaultServerWebExchange(this.request, response, new MockWebSessionManager()); + } + + + @Test + public void missingContentType() throws Exception { + String body = "{\"bar\":\"BARBAR\",\"foo\":\"FOOFOO\"}"; + this.request.writeWith(Flux.just(dataBuffer(body))); + ResolvableType type = forClassWithGenerics(Mono.class, TestBean.class); + MethodParameter param = this.testMethod.resolveParam(type); + Mono result = this.resolver.readBody(param, true, this.exchange); + + TestSubscriber.subscribe(result) + .assertError(UnsupportedMediaTypeStatusException.class); + } + + // More extensive "empty body" tests in RequestBody- and HttpEntityArgumentResolverTests + + @Test @SuppressWarnings("unchecked") // SPR-9942 + public void emptyBody() throws Exception { + this.request.writeWith(Flux.empty()); + this.request.getHeaders().setContentType(MediaType.APPLICATION_JSON); + ResolvableType type = forClassWithGenerics(Mono.class, TestBean.class); + MethodParameter param = this.testMethod.resolveParam(type); + Mono result = (Mono) this.resolver.readBody(param, true, this.exchange).block(); + + TestSubscriber.subscribe(result).assertError(ServerWebInputException.class); + } + + @Test + public void monoTestBean() throws Exception { + String body = "{\"bar\":\"BARBAR\",\"foo\":\"FOOFOO\"}"; + ResolvableType type = forClassWithGenerics(Mono.class, TestBean.class); + MethodParameter param = this.testMethod.resolveParam(type); + Mono mono = resolveValue(param, body); + + assertEquals(new TestBean("FOOFOO", "BARBAR"), mono.block()); + } + + @Test + public void fluxTestBean() throws Exception { + String body = "[{\"bar\":\"b1\",\"foo\":\"f1\"},{\"bar\":\"b2\",\"foo\":\"f2\"}]"; + ResolvableType type = forClassWithGenerics(Flux.class, TestBean.class); + MethodParameter param = this.testMethod.resolveParam(type); + Flux flux = resolveValue(param, body); + + assertEquals(Arrays.asList(new TestBean("f1", "b1"), new TestBean("f2", "b2")), + flux.collectList().block()); + } + + @Test + public void singleTestBean() throws Exception { + String body = "{\"bar\":\"b1\",\"foo\":\"f1\"}"; + ResolvableType type = forClassWithGenerics(Single.class, TestBean.class); + MethodParameter param = this.testMethod.resolveParam(type); + Single single = resolveValue(param, body); + + assertEquals(new TestBean("f1", "b1"), single.toBlocking().value()); + } + + @Test + public void observableTestBean() throws Exception { + String body = "[{\"bar\":\"b1\",\"foo\":\"f1\"},{\"bar\":\"b2\",\"foo\":\"f2\"}]"; + ResolvableType type = forClassWithGenerics(Observable.class, TestBean.class); + MethodParameter param = this.testMethod.resolveParam(type); + Observable observable = resolveValue(param, body); + + assertEquals(Arrays.asList(new TestBean("f1", "b1"), new TestBean("f2", "b2")), + observable.toList().toBlocking().first()); + } + + @Test + public void futureTestBean() throws Exception { + String body = "{\"bar\":\"b1\",\"foo\":\"f1\"}"; + ResolvableType type = forClassWithGenerics(CompletableFuture.class, TestBean.class); + MethodParameter param = this.testMethod.resolveParam(type); + CompletableFuture future = resolveValue(param, body); + + assertEquals(new TestBean("f1", "b1"), future.get()); + } + + @Test + public void testBean() throws Exception { + String body = "{\"bar\":\"b1\",\"foo\":\"f1\"}"; + MethodParameter param = this.testMethod.resolveParam(forClass(TestBean.class)); + TestBean value = resolveValue(param, body); + + assertEquals(new TestBean("f1", "b1"), value); + } + + @Test + public void map() throws Exception { + String body = "{\"bar\":\"b1\",\"foo\":\"f1\"}"; + Map map = new HashMap<>(); + map.put("foo", "f1"); + map.put("bar", "b1"); + ResolvableType type = forClassWithGenerics(Map.class, String.class, String.class); + MethodParameter param = this.testMethod.resolveParam(type); + Map actual = resolveValue(param, body); + + assertEquals(map, actual); + } + + @Test + public void list() throws Exception { + String body = "[{\"bar\":\"b1\",\"foo\":\"f1\"},{\"bar\":\"b2\",\"foo\":\"f2\"}]"; + ResolvableType type = forClassWithGenerics(List.class, TestBean.class); + MethodParameter param = this.testMethod.resolveParam(type); + List list = resolveValue(param, body); + + assertEquals(Arrays.asList(new TestBean("f1", "b1"), new TestBean("f2", "b2")), list); + } + + @Test + public void monoList() throws Exception { + String body = "[{\"bar\":\"b1\",\"foo\":\"f1\"},{\"bar\":\"b2\",\"foo\":\"f2\"}]"; + ResolvableType type = forClassWithGenerics(Mono.class, forClassWithGenerics(List.class, TestBean.class)); + MethodParameter param = this.testMethod.resolveParam(type); + Mono mono = resolveValue(param, body); + + List list = (List) mono.block(Duration.ofSeconds(5)); + assertEquals(Arrays.asList(new TestBean("f1", "b1"), new TestBean("f2", "b2")), list); + } + + @Test + public void array() throws Exception { + String body = "[{\"bar\":\"b1\",\"foo\":\"f1\"},{\"bar\":\"b2\",\"foo\":\"f2\"}]"; + ResolvableType type = forClass(TestBean[].class); + MethodParameter param = this.testMethod.resolveParam(type); + TestBean[] value = resolveValue(param, body); + + assertArrayEquals(new TestBean[] {new TestBean("f1", "b1"), new TestBean("f2", "b2")}, value); + } + + @Test @SuppressWarnings("unchecked") + public void validateMonoTestBean() throws Exception { + String body = "{\"bar\":\"b1\"}"; + ResolvableType type = forClassWithGenerics(Mono.class, TestBean.class); + MethodParameter param = this.testMethod.resolveParam(type); + Mono mono = resolveValue(param, body); + + TestSubscriber.subscribe(mono) + .assertNoValues() + .assertError(ServerWebInputException.class); + } + + @Test @SuppressWarnings("unchecked") + public void validateFluxTestBean() throws Exception { + String body = "[{\"bar\":\"b1\",\"foo\":\"f1\"},{\"bar\":\"b2\"}]"; + ResolvableType type = forClassWithGenerics(Flux.class, TestBean.class); + MethodParameter param = this.testMethod.resolveParam(type); + Flux flux = resolveValue(param, body); + + TestSubscriber.subscribe(flux) + .assertValues(new TestBean("f1", "b1")) + .assertError(ServerWebInputException.class); + } + + @Test // SPR-9964 + @Ignore + public void parameterizedMethodArgument() throws Exception { + Class clazz = ConcreteParameterizedController.class; + MethodParameter param = ResolvableMethod.onClass(clazz).name("handleDto").resolveParam(); + SimpleBean simpleBean = resolveValue(param, "{\"name\" : \"Jad\"}"); + + assertEquals("Jad", simpleBean.getName()); + } + + + @SuppressWarnings("unchecked") + private T resolveValue(MethodParameter param, String body) { + + this.request.getHeaders().setContentType(MediaType.APPLICATION_JSON); + this.request.writeWith(Flux.just(dataBuffer(body))); + + Mono result = this.resolver.readBody(param, true, this.exchange); + Object value = result.block(Duration.ofSeconds(5)); + + assertNotNull(value); + assertTrue("Unexpected return value type: " + value, + param.getParameterType().isAssignableFrom(value.getClass())); + + return (T) value; + } + + @SuppressWarnings("Convert2MethodRef") + private AbstractMessageConverterArgumentResolver resolver(Decoder... decoders) { + + List> converters = new ArrayList<>(); + Arrays.asList(decoders).forEach(decoder -> converters.add(new CodecHttpMessageConverter<>(decoder))); + + FormattingConversionService service = new DefaultFormattingConversionService(); + service.addConverter(new MonoToCompletableFutureConverter()); + service.addConverter(new ReactorToRxJava1Converter()); + + return new AbstractMessageConverterArgumentResolver(converters, service, new TestBeanValidator()) {}; + } + + private DataBuffer dataBuffer(String body) { + byte[] bytes = body.getBytes(Charset.forName("UTF-8")); + ByteBuffer byteBuffer = ByteBuffer.wrap(bytes); + return new DefaultDataBufferFactory().wrap(byteBuffer); + } + + + @SuppressWarnings("unused") + private void handle( + @Validated Mono monoTestBean, + @Validated Flux fluxTestBean, + Single singleTestBean, + Observable observableTestBean, + CompletableFuture futureTestBean, + TestBean testBean, + Map map, + List list, + Mono> monoList, + Set set, + TestBean[] array) {} + + + @XmlRootElement + private static class TestBean { + + private String foo; + + private String bar; + + @SuppressWarnings("unused") + public TestBean() { + } + + TestBean(String foo, String bar) { + this.foo = foo; + this.bar = bar; + } + + public String getFoo() { + return this.foo; + } + + public void setFoo(String foo) { + this.foo = foo; + } + + public String getBar() { + return this.bar; + } + + public void setBar(String bar) { + this.bar = bar; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o instanceof TestBean) { + TestBean other = (TestBean) o; + return this.foo.equals(other.foo) && this.bar.equals(other.bar); + } + return false; + } + + @Override + public int hashCode() { + return 31 * foo.hashCode() + bar.hashCode(); + } + + @Override + public String toString() { + return "TestBean[foo='" + this.foo + "\'" + ", bar='" + this.bar + "\']"; + } + } + + private static class TestBeanValidator implements Validator { + + @Override + public boolean supports(Class clazz) { + return clazz.equals(TestBean.class); + } + + @Override + public void validate(Object target, Errors errors) { + TestBean testBean = (TestBean) target; + if (testBean.getFoo() == null) { + errors.rejectValue("foo", "nullValue"); + } + } + } + + private static abstract class AbstractParameterizedController { + + @SuppressWarnings("unused") + public void handleDto(DTO dto) {} + } + + private static class ConcreteParameterizedController extends AbstractParameterizedController { + } + + private interface Identifiable extends Serializable { + + Long getId(); + + void setId(Long id); + } + + @SuppressWarnings({ "serial" }) + private static class SimpleBean implements Identifiable { + + private Long id; + + private String name; + + @Override + public Long getId() { + return id; + } + + @Override + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/MessageConverterResultHandlerTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/MessageConverterResultHandlerTests.java new file mode 100644 index 0000000000..256ea69891 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/MessageConverterResultHandlerTests.java @@ -0,0 +1,299 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import java.io.ByteArrayOutputStream; +import java.io.OutputStream; +import java.io.Serializable; +import java.net.URI; +import java.nio.charset.Charset; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeName; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.test.TestSubscriber; +import rx.Observable; + +import org.springframework.core.MethodParameter; +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.ByteBufferEncoder; +import org.springframework.core.codec.StringEncoder; +import org.springframework.core.convert.support.GenericConversionService; +import org.springframework.core.convert.support.MonoToCompletableFutureConverter; +import org.springframework.core.convert.support.ReactorToRxJava1Converter; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.buffer.support.DataBufferTestUtils; +import org.springframework.http.HttpMethod; +import org.springframework.http.codec.json.JacksonJsonEncoder; +import org.springframework.http.codec.xml.Jaxb2Encoder; +import org.springframework.http.converter.reactive.CodecHttpMessageConverter; +import org.springframework.http.converter.reactive.HttpMessageConverter; +import org.springframework.http.converter.reactive.ResourceHttpMessageConverter; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.util.ObjectUtils; +import org.springframework.web.reactive.accept.RequestedContentTypeResolver; +import org.springframework.web.reactive.accept.RequestedContentTypeResolverBuilder; +import org.springframework.web.reactive.result.ResolvableMethod; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.http.MediaType.APPLICATION_JSON_UTF8; +import static org.springframework.web.reactive.HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE; + +/** + * Unit tests for {@link AbstractMessageConverterResultHandler}. + * @author Rossen Stoyanchev + */ +public class MessageConverterResultHandlerTests { + + private AbstractMessageConverterResultHandler resultHandler; + + private MockServerHttpResponse response = new MockServerHttpResponse(); + + private ServerWebExchange exchange; + + + @Before + public void setUp() throws Exception { + this.resultHandler = createResultHandler(); + ServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, new URI("/path")); + this.exchange = new DefaultServerWebExchange(request, this.response, new MockWebSessionManager()); + } + + + @Test // SPR-12894 + public void useDefaultContentType() throws Exception { + Resource body = new ClassPathResource("logo.png", getClass()); + ResolvableType type = ResolvableType.forType(Resource.class); + this.resultHandler.writeBody(this.exchange, body, type, returnType(type)).block(Duration.ofSeconds(5)); + + assertEquals("image/x-png", this.response.getHeaders().getFirst("Content-Type")); + } + + @Test // SPR-13631 + public void useDefaultCharset() throws Exception { + this.exchange.getAttributes().put(PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE, + Collections.singleton(APPLICATION_JSON)); + + String body = "foo"; + ResolvableType type = ResolvableType.forType(String.class); + this.resultHandler.writeBody(this.exchange, body, type, returnType(type)).block(Duration.ofSeconds(5)); + + assertEquals(APPLICATION_JSON_UTF8, this.response.getHeaders().getContentType()); + } + + @Test + public void voidReturnType() throws Exception { + testVoidReturnType(null, ResolvableType.forType(void.class)); + testVoidReturnType(Mono.empty(), ResolvableType.forClassWithGenerics(Mono.class, Void.class)); + testVoidReturnType(Flux.empty(), ResolvableType.forClassWithGenerics(Flux.class, Void.class)); + testVoidReturnType(Observable.empty(), ResolvableType.forClassWithGenerics(Observable.class, Void.class)); + } + + private void testVoidReturnType(Object body, ResolvableType type) { + this.resultHandler.writeBody(this.exchange, body, type, returnType(type)).block(Duration.ofSeconds(5)); + + assertNull(this.response.getHeaders().get("Content-Type")); + assertNull(this.response.getBody()); + } + + @Test // SPR-13135 + public void unsupportedReturnType() throws Exception { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + ResolvableType type = ResolvableType.forType(OutputStream.class); + + HttpMessageConverter converter = new CodecHttpMessageConverter<>(new ByteBufferEncoder()); + Mono mono = createResultHandler(converter).writeBody(this.exchange, body, type, returnType(type)); + + TestSubscriber.subscribe(mono).assertError(IllegalStateException.class); + } + + @Test // SPR-12811 + public void jacksonTypeOfListElement() throws Exception { + List body = Arrays.asList(new Foo("foo"), new Bar("bar")); + ResolvableType type = ResolvableType.forClassWithGenerics(List.class, ParentClass.class); + this.resultHandler.writeBody(this.exchange, body, type, returnType(type)).block(Duration.ofSeconds(5)); + + assertEquals(APPLICATION_JSON_UTF8, this.response.getHeaders().getContentType()); + assertResponseBody("[{\"type\":\"foo\",\"parentProperty\":\"foo\"}," + + "{\"type\":\"bar\",\"parentProperty\":\"bar\"}]"); + } + + @Test // SPR-13318 + @Ignore + public void jacksonTypeWithSubType() throws Exception { + SimpleBean body = new SimpleBean(123L, "foo"); + ResolvableType type = ResolvableType.forClass(Identifiable.class); + this.resultHandler.writeBody(this.exchange, body, type, returnType(type)).block(Duration.ofSeconds(5)); + + assertEquals(APPLICATION_JSON_UTF8, this.response.getHeaders().getContentType()); + assertResponseBody("{\"id\":123,\"name\":\"foo\"}"); + } + + @Test // SPR-13318 + @Ignore + public void jacksonTypeWithSubTypeOfListElement() throws Exception { + List body = Arrays.asList(new SimpleBean(123L, "foo"), new SimpleBean(456L, "bar")); + ResolvableType type = ResolvableType.forClassWithGenerics(List.class, Identifiable.class); + this.resultHandler.writeBody(this.exchange, body, type, returnType(type)).block(Duration.ofSeconds(5)); + + assertEquals(APPLICATION_JSON_UTF8, this.response.getHeaders().getContentType()); + assertResponseBody("[{\"id\":123,\"name\":\"foo\"},{\"id\":456,\"name\":\"bar\"}]"); + } + + + private MethodParameter returnType(ResolvableType bodyType) { + return ResolvableMethod.onClass(TestController.class).returning(bodyType).resolveReturnType(); + } + + private AbstractMessageConverterResultHandler createResultHandler(HttpMessageConverter... converters) { + List> converterList; + if (ObjectUtils.isEmpty(converters)) { + converterList = new ArrayList<>(); + converterList.add(new CodecHttpMessageConverter<>(new ByteBufferEncoder())); + converterList.add(new CodecHttpMessageConverter<>(new StringEncoder())); + converterList.add(new ResourceHttpMessageConverter()); + converterList.add(new CodecHttpMessageConverter<>(new Jaxb2Encoder())); + converterList.add(new CodecHttpMessageConverter<>(new JacksonJsonEncoder())); + } + else { + converterList = Arrays.asList(converters); + } + + GenericConversionService service = new GenericConversionService(); + service.addConverter(new MonoToCompletableFutureConverter()); + service.addConverter(new ReactorToRxJava1Converter()); + + RequestedContentTypeResolver resolver = new RequestedContentTypeResolverBuilder().build(); + + return new AbstractMessageConverterResultHandler(converterList, service, resolver) {}; + } + + private void assertResponseBody(String responseBody) { + TestSubscriber.subscribe(this.response.getBody()) + .assertValuesWith(buf -> assertEquals(responseBody, + DataBufferTestUtils.dumpString(buf, Charset.forName("UTF-8")))); + } + + + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") + @SuppressWarnings("unused") + private static class ParentClass { + + private String parentProperty; + + public ParentClass() { + } + + public ParentClass(String parentProperty) { + this.parentProperty = parentProperty; + } + + public String getParentProperty() { + return parentProperty; + } + + public void setParentProperty(String parentProperty) { + this.parentProperty = parentProperty; + } + } + + @JsonTypeName("foo") + private static class Foo extends ParentClass { + + public Foo(String parentProperty) { + super(parentProperty); + } + } + + @JsonTypeName("bar") + private static class Bar extends ParentClass { + + public Bar(String parentProperty) { + super(parentProperty); + } + } + + private interface Identifiable extends Serializable { + + @SuppressWarnings("unused") + Long getId(); + } + + @SuppressWarnings({ "serial" }) + private static class SimpleBean implements Identifiable { + + private Long id; + + private String name; + + public SimpleBean(Long id, String name) { + this.id = id; + this.name = name; + } + + @Override + public Long getId() { + return id; + } + + public String getName() { + return name; + } + } + + @SuppressWarnings("unused") + private static class TestController { + + Resource resource() { return null; } + + String string() { return null; } + + void voidReturn() { } + + Mono monoVoid() { return null; } + + Flux fluxVoid() { return null; } + + Observable observableVoid() { return null; } + + OutputStream outputStream() { return null; } + + List listParentClass() { return null; } + + Identifiable identifiable() { return null; } + + List listIdentifiable() { return null; } + + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/PathVariableMapMethodArgumentResolverTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/PathVariableMapMethodArgumentResolverTests.java new file mode 100644 index 0000000000..1399871183 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/PathVariableMapMethodArgumentResolverTests.java @@ -0,0 +1,112 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import java.lang.reflect.Method; +import java.net.URI; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpMethod; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.ui.ModelMap; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.reactive.HandlerMapping; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; +import org.springframework.web.server.session.WebSessionManager; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Unit tests for {@link PathVariableMapMethodArgumentResolver}. + * + * @author Rossen Stoyanchev + */ +public class PathVariableMapMethodArgumentResolverTests { + + private PathVariableMapMethodArgumentResolver resolver; + + private ServerWebExchange exchange; + + private MethodParameter paramMap; + private MethodParameter paramNamedMap; + private MethodParameter paramMapNoAnnot; + + + @Before + public void setUp() throws Exception { + this.resolver = new PathVariableMapMethodArgumentResolver(); + + ServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, new URI("/")); + WebSessionManager sessionManager = new MockWebSessionManager(); + this.exchange = new DefaultServerWebExchange(request, new MockServerHttpResponse(), sessionManager); + + Method method = getClass().getMethod("handle", Map.class, Map.class, Map.class); + this.paramMap = new MethodParameter(method, 0); + this.paramNamedMap = new MethodParameter(method, 1); + this.paramMapNoAnnot = new MethodParameter(method, 2); + } + + @Test + public void supportsParameter() { + assertTrue(resolver.supportsParameter(paramMap)); + assertFalse(resolver.supportsParameter(paramNamedMap)); + assertFalse(resolver.supportsParameter(paramMapNoAnnot)); + } + + @Test + public void resolveArgument() throws Exception { + Map uriTemplateVars = new HashMap<>(); + uriTemplateVars.put("name1", "value1"); + uriTemplateVars.put("name2", "value2"); + this.exchange.getAttributes().put(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE, uriTemplateVars); + + Mono mono = this.resolver.resolveArgument(this.paramMap, new ModelMap(), this.exchange); + Object result = mono.block(); + + assertEquals(uriTemplateVars, result); + } + + @Test + public void resolveArgumentNoUriVars() throws Exception { + Mono mono = this.resolver.resolveArgument(this.paramMap, new ModelMap(), this.exchange); + Object result = mono.block(); + + assertEquals(Collections.emptyMap(), result); + } + + + @SuppressWarnings("unused") + public void handle( + @PathVariable Map map, + @PathVariable(value = "name") Map namedMap, + Map mapWithoutAnnotat) { + } + +} \ No newline at end of file diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/PathVariableMethodArgumentResolverTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/PathVariableMethodArgumentResolverTests.java new file mode 100644 index 0000000000..8e93eb0d54 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/PathVariableMethodArgumentResolverTests.java @@ -0,0 +1,110 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import java.lang.reflect.Method; +import java.net.URI; +import java.util.HashMap; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Mono; +import reactor.core.test.TestSubscriber; + +import org.springframework.core.MethodParameter; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.http.HttpMethod; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.ui.ModelMap; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.reactive.HandlerMapping; +import org.springframework.web.server.ServerErrorException; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; +import org.springframework.web.server.session.WebSessionManager; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Unit tests for {@link PathVariableMethodArgumentResolver}. + * + * @author Rossen Stoyanchev + */ +public class PathVariableMethodArgumentResolverTests { + + private PathVariableMethodArgumentResolver resolver; + + private ServerWebExchange exchange; + + private MethodParameter paramNamedString; + private MethodParameter paramString; + + + @Before + public void setUp() throws Exception { + ConversionService conversionService = new DefaultConversionService(); + this.resolver = new PathVariableMethodArgumentResolver(conversionService, null); + + ServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, new URI("/")); + WebSessionManager sessionManager = new MockWebSessionManager(); + this.exchange = new DefaultServerWebExchange(request, new MockServerHttpResponse(), sessionManager); + + Method method = getClass().getMethod("handle", String.class, String.class); + this.paramNamedString = new MethodParameter(method, 0); + this.paramString = new MethodParameter(method, 1); + } + + + @Test + public void supportsParameter() { + assertTrue(this.resolver.supportsParameter(this.paramNamedString)); + assertFalse(this.resolver.supportsParameter(this.paramString)); + } + + @Test + public void resolveArgument() throws Exception { + Map uriTemplateVars = new HashMap<>(); + uriTemplateVars.put("name", "value"); + this.exchange.getAttributes().put(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE, uriTemplateVars); + + Mono mono = this.resolver.resolveArgument(this.paramNamedString, new ModelMap(), this.exchange); + Object result = mono.block(); + + assertTrue(result instanceof String); + assertEquals("value", result); + } + + @Test + public void handleMissingValue() throws Exception { + Mono mono = this.resolver.resolveArgument(this.paramNamedString, new ModelMap(), this.exchange); + TestSubscriber + .subscribe(mono) + .assertError(ServerErrorException.class); + } + + @SuppressWarnings("unused") + public void handle(@PathVariable(value = "name") String param1, String param2) { + } + +} \ No newline at end of file diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestAttributeMethodArgumentResolverTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestAttributeMethodArgumentResolverTests.java new file mode 100644 index 0000000000..2a88c4fbe8 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestAttributeMethodArgumentResolverTests.java @@ -0,0 +1,164 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import java.lang.reflect.Method; +import java.net.URI; +import java.util.Optional; + +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Mono; +import reactor.core.test.TestSubscriber; + +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.core.DefaultParameterNameDiscoverer; +import org.springframework.core.GenericTypeResolver; +import org.springframework.core.MethodParameter; +import org.springframework.core.annotation.SynthesizingMethodParameter; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.http.HttpMethod; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.util.ReflectionUtils; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.ServerWebInputException; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; +import org.springframework.web.server.session.WebSessionManager; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + + +/** + * Unit tests for {@link RequestAttributeMethodArgumentResolver}. + * @author Rossen Stoyanchev + */ +public class RequestAttributeMethodArgumentResolverTests { + + private RequestAttributeMethodArgumentResolver resolver; + + private ServerWebExchange exchange; + + private Method handleMethod; + + + @Before + @SuppressWarnings("ConfusingArgumentToVarargsMethod") + public void setUp() throws Exception { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.refresh(); + ConversionService cs = new DefaultConversionService(); + this.resolver = new RequestAttributeMethodArgumentResolver(cs, context.getBeanFactory()); + + ServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, new URI("/")); + WebSessionManager sessionManager = new MockWebSessionManager(); + this.exchange = new DefaultServerWebExchange(request, new MockServerHttpResponse(), sessionManager); + + this.handleMethod = ReflectionUtils.findMethod(getClass(), "handleWithRequestAttribute", (Class[]) null); + } + + + @Test + public void supportsParameter() throws Exception { + assertTrue(this.resolver.supportsParameter(new MethodParameter(this.handleMethod, 0))); + assertFalse(this.resolver.supportsParameter(new MethodParameter(this.handleMethod, 4))); + } + + @Test + public void resolve() throws Exception { + MethodParameter param = initMethodParameter(0); + Mono mono = this.resolver.resolveArgument(param, null, this.exchange); + TestSubscriber + .subscribe(mono) + .assertError(ServerWebInputException.class); + + Foo foo = new Foo(); + this.exchange.getAttributes().put("foo", foo); + mono = this.resolver.resolveArgument(param, null, this.exchange); + assertSame(foo, mono.block()); + } + + @Test + public void resolveWithName() throws Exception { + MethodParameter param = initMethodParameter(1); + Foo foo = new Foo(); + this.exchange.getAttributes().put("specialFoo", foo); + Mono mono = this.resolver.resolveArgument(param, null, this.exchange); + assertSame(foo, mono.block()); + } + + @Test + public void resolveNotRequired() throws Exception { + MethodParameter param = initMethodParameter(2); + Mono mono = this.resolver.resolveArgument(param, null, this.exchange); + assertNull(mono.block()); + + Foo foo = new Foo(); + this.exchange.getAttributes().put("foo", foo); + mono = this.resolver.resolveArgument(param, null, this.exchange); + assertSame(foo, mono.block()); + } + + @Test + public void resolveOptional() throws Exception { + MethodParameter param = initMethodParameter(3); + Mono mono = this.resolver.resolveArgument(param, null, this.exchange); + assertNotNull(mono.block()); + assertEquals(Optional.class, mono.block().getClass()); + assertFalse(((Optional) mono.block()).isPresent()); + + Foo foo = new Foo(); + this.exchange.getAttributes().put("foo", foo); + mono = this.resolver.resolveArgument(param, null, this.exchange); + + assertNotNull(mono.block()); + assertEquals(Optional.class, mono.block().getClass()); + Optional optional = (Optional) mono.block(); + assertTrue(optional.isPresent()); + assertSame(foo, optional.get()); + } + + + private MethodParameter initMethodParameter(int parameterIndex) { + MethodParameter param = new SynthesizingMethodParameter(this.handleMethod, parameterIndex); + param.initParameterNameDiscovery(new DefaultParameterNameDiscoverer()); + GenericTypeResolver.resolveParameterType(param, this.resolver.getClass()); + return param; + } + + + @SuppressWarnings({"unused", "OptionalUsedAsFieldOrParameterType"}) + private void handleWithRequestAttribute( + @RequestAttribute Foo foo, + @RequestAttribute("specialFoo") Foo namedFoo, + @RequestAttribute(name="foo", required = false) Foo notRequiredFoo, + @RequestAttribute(name="foo") Optional optionalFoo, + String notSupported) { + } + + private static class Foo { + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestBodyArgumentResolverTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestBodyArgumentResolverTests.java new file mode 100644 index 0000000000..b75c5aa017 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestBodyArgumentResolverTests.java @@ -0,0 +1,269 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.function.Predicate; + +import org.junit.Before; +import org.junit.Test; +import reactor.core.converter.RxJava1ObservableConverter; +import reactor.core.converter.RxJava1SingleConverter; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.test.TestSubscriber; +import rx.Observable; +import rx.Single; + +import org.springframework.core.MethodParameter; +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.StringDecoder; +import org.springframework.core.convert.support.MonoToCompletableFutureConverter; +import org.springframework.core.convert.support.ReactorToRxJava1Converter; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.format.support.DefaultFormattingConversionService; +import org.springframework.format.support.FormattingConversionService; +import org.springframework.http.HttpMethod; +import org.springframework.http.converter.reactive.CodecHttpMessageConverter; +import org.springframework.http.converter.reactive.HttpMessageConverter; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.ui.ExtendedModelMap; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.reactive.result.ResolvableMethod; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.ServerWebInputException; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.springframework.core.ResolvableType.forClass; +import static org.springframework.core.ResolvableType.forClassWithGenerics; + +/** + * Unit tests for {@link RequestBodyArgumentResolver}.When adding a test also + * consider whether the logic under test is in a parent class, then see: + * {@link MessageConverterArgumentResolverTests}. + * + * @author Rossen Stoyanchev + */ +public class RequestBodyArgumentResolverTests { + + private RequestBodyArgumentResolver resolver = resolver(); + + private ServerWebExchange exchange; + + private MockServerHttpRequest request; + + private ResolvableMethod testMethod = ResolvableMethod.onClass(this.getClass()).name("handle"); + + + @Before + public void setUp() throws Exception { + this.request = new MockServerHttpRequest(HttpMethod.POST, new URI("/path")); + MockServerHttpResponse response = new MockServerHttpResponse(); + this.exchange = new DefaultServerWebExchange(this.request, response, new MockWebSessionManager()); + } + + private RequestBodyArgumentResolver resolver() { + List> converters = new ArrayList<>(); + converters.add(new CodecHttpMessageConverter<>(new StringDecoder())); + + FormattingConversionService service = new DefaultFormattingConversionService(); + service.addConverter(new MonoToCompletableFutureConverter()); + service.addConverter(new ReactorToRxJava1Converter()); + + return new RequestBodyArgumentResolver(converters, service); + } + + + @Test + public void supports() throws Exception { + ResolvableType type = forClassWithGenerics(Mono.class, String.class); + MethodParameter param = this.testMethod.resolveParam(type, requestBody(true)); + assertTrue(this.resolver.supportsParameter(param)); + + MethodParameter parameter = this.testMethod.resolveParam(p -> !p.hasParameterAnnotations()); + assertFalse(this.resolver.supportsParameter(parameter)); + } + + @Test + public void stringBody() throws Exception { + String body = "line1"; + ResolvableType type = forClass(String.class); + MethodParameter param = this.testMethod.resolveParam(type, requestBody(true)); + String value = resolveValue(param, body); + + assertEquals(body, value); + } + + @Test(expected = ServerWebInputException.class) + public void emptyBodyWithString() throws Exception { + resolveValueWithEmptyBody(forClass(String.class), true); + } + + @Test + public void emptyBodyWithStringNotRequired() throws Exception { + ResolvableType type = forClass(String.class); + String body = resolveValueWithEmptyBody(type, false); + + assertNull(body); + } + + @Test + public void emptyBodyWithMono() throws Exception { + ResolvableType type = forClassWithGenerics(Mono.class, String.class); + + TestSubscriber.subscribe(resolveValueWithEmptyBody(type, true)) + .assertNoValues() + .assertError(ServerWebInputException.class); + + TestSubscriber.subscribe(resolveValueWithEmptyBody(type, false)) + .assertNoValues() + .assertComplete(); + } + + @Test + public void emptyBodyWithFlux() throws Exception { + ResolvableType type = forClassWithGenerics(Flux.class, String.class); + + TestSubscriber.subscribe(resolveValueWithEmptyBody(type, true)) + .assertNoValues() + .assertError(ServerWebInputException.class); + + TestSubscriber.subscribe(resolveValueWithEmptyBody(type, false)) + .assertNoValues() + .assertComplete(); + } + + @Test + public void emptyBodyWithSingle() throws Exception { + ResolvableType type = forClassWithGenerics(Single.class, String.class); + + Single single = resolveValueWithEmptyBody(type, true); + TestSubscriber.subscribe(RxJava1SingleConverter.toPublisher(single)) + .assertNoValues() + .assertError(ServerWebInputException.class); + + single = resolveValueWithEmptyBody(type, false); + TestSubscriber.subscribe(RxJava1SingleConverter.toPublisher(single)) + .assertNoValues() + .assertError(ServerWebInputException.class); + } + + @Test + public void emptyBodyWithObservable() throws Exception { + ResolvableType type = forClassWithGenerics(Observable.class, String.class); + + Observable observable = resolveValueWithEmptyBody(type, true); + TestSubscriber.subscribe(RxJava1ObservableConverter.toPublisher(observable)) + .assertNoValues() + .assertError(ServerWebInputException.class); + + observable = resolveValueWithEmptyBody(type, false); + TestSubscriber.subscribe(RxJava1ObservableConverter.toPublisher(observable)) + .assertNoValues() + .assertComplete(); + } + + @Test + public void emptyBodyWithCompletableFuture() throws Exception { + ResolvableType type = forClassWithGenerics(CompletableFuture.class, String.class); + + CompletableFuture future = resolveValueWithEmptyBody(type, true); + future.whenComplete((text, ex) -> { + assertNull(text); + assertNotNull(ex); + }); + + future = resolveValueWithEmptyBody(type, false); + future.whenComplete((text, ex) -> { + assertNotNull(text); + assertNull(ex); + }); + } + + + private T resolveValue(MethodParameter param, String body) { + this.request.writeWith(Flux.just(dataBuffer(body))); + Mono result = this.resolver.readBody(param, true, this.exchange); + Object value = result.block(Duration.ofSeconds(5)); + + assertNotNull(value); + assertTrue("Unexpected return value type: " + value, + param.getParameterType().isAssignableFrom(value.getClass())); + + //noinspection unchecked + return (T) value; + } + + private T resolveValueWithEmptyBody(ResolvableType bodyType, boolean isRequired) { + this.request.writeWith(Flux.empty()); + MethodParameter param = this.testMethod.resolveParam(bodyType, requestBody(isRequired)); + Mono result = this.resolver.resolveArgument(param, new ExtendedModelMap(), this.exchange); + Object value = result.block(Duration.ofSeconds(5)); + + if (value != null) { + assertTrue("Unexpected return value type: " + value, + param.getParameterType().isAssignableFrom(value.getClass())); + } + + //noinspection unchecked + return (T) value; + } + + private Predicate requestBody(boolean required) { + return p -> { + RequestBody annotation = p.getParameterAnnotation(RequestBody.class); + return annotation != null && annotation.required() == required; + }; + } + + private DataBuffer dataBuffer(String body) { + byte[] bytes = body.getBytes(Charset.forName("UTF-8")); + ByteBuffer byteBuffer = ByteBuffer.wrap(bytes); + return new DefaultDataBufferFactory().wrap(byteBuffer); + } + + + @SuppressWarnings("unused") + void handle( + @RequestBody String string, + @RequestBody Mono mono, + @RequestBody Flux flux, + @RequestBody Single single, + @RequestBody Observable obs, + @RequestBody CompletableFuture future, + @RequestBody(required = false) String stringNotRequired, + @RequestBody(required = false) Mono monoNotRequired, + @RequestBody(required = false) Flux fluxNotRequired, + @RequestBody(required = false) Single singleNotRequired, + @RequestBody(required = false) Observable obsNotRequired, + @RequestBody(required = false) CompletableFuture futureNotRequired, + String notAnnotated) {} + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestHeaderMapMethodArgumentResolverTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestHeaderMapMethodArgumentResolverTests.java new file mode 100644 index 0000000000..864dd389f7 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestHeaderMapMethodArgumentResolverTests.java @@ -0,0 +1,150 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import java.lang.reflect.Method; +import java.net.URI; +import java.util.Collections; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.core.annotation.SynthesizingMethodParameter; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; +import org.springframework.web.server.session.WebSessionManager; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Unit tests for {@link RequestHeaderMapMethodArgumentResolver}. + * + * @author Rossen Stoyanchev + */ +public class RequestHeaderMapMethodArgumentResolverTests { + + private RequestHeaderMapMethodArgumentResolver resolver; + + private MethodParameter paramMap; + private MethodParameter paramMultiValueMap; + private MethodParameter paramHttpHeaders; + private MethodParameter paramUnsupported; + + private ServerWebExchange exchange; + + + @Before + public void setUp() throws Exception { + resolver = new RequestHeaderMapMethodArgumentResolver(); + + Method method = getClass().getMethod("params", Map.class, MultiValueMap.class, HttpHeaders.class, Map.class); + paramMap = new SynthesizingMethodParameter(method, 0); + paramMultiValueMap = new SynthesizingMethodParameter(method, 1); + paramHttpHeaders = new SynthesizingMethodParameter(method, 2); + paramUnsupported = new SynthesizingMethodParameter(method, 3); + + ServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, new URI("/")); + WebSessionManager sessionManager = new MockWebSessionManager(); + this.exchange = new DefaultServerWebExchange(request, new MockServerHttpResponse(), sessionManager); + } + + + @Test + public void supportsParameter() { + assertTrue("Map parameter not supported", resolver.supportsParameter(paramMap)); + assertTrue("MultiValueMap parameter not supported", resolver.supportsParameter(paramMultiValueMap)); + assertTrue("HttpHeaders parameter not supported", resolver.supportsParameter(paramHttpHeaders)); + assertFalse("non-@RequestParam map supported", resolver.supportsParameter(paramUnsupported)); + } + + @Test + public void resolveMapArgument() throws Exception { + String name = "foo"; + String value = "bar"; + Map expected = Collections.singletonMap(name, value); + this.exchange.getRequest().getHeaders().add(name, value); + + Mono mono = this.resolver.resolveArgument(paramMap, null, this.exchange); + Object result = mono.block(); + + assertTrue(result instanceof Map); + assertEquals("Invalid result", expected, result); + } + + @Test + public void resolveMultiValueMapArgument() throws Exception { + String name = "foo"; + String value1 = "bar"; + String value2 = "baz"; + + this.exchange.getRequest().getHeaders().add(name, value1); + this.exchange.getRequest().getHeaders().add(name, value2); + + MultiValueMap expected = new LinkedMultiValueMap<>(1); + expected.add(name, value1); + expected.add(name, value2); + + Mono mono = this.resolver.resolveArgument(paramMultiValueMap, null, this.exchange); + Object result = mono.block(); + + assertTrue(result instanceof MultiValueMap); + assertEquals("Invalid result", expected, result); + } + + @Test + public void resolveHttpHeadersArgument() throws Exception { + String name = "foo"; + String value1 = "bar"; + String value2 = "baz"; + + this.exchange.getRequest().getHeaders().add(name, value1); + this.exchange.getRequest().getHeaders().add(name, value2); + + HttpHeaders expected = new HttpHeaders(); + expected.add(name, value1); + expected.add(name, value2); + + Mono mono = this.resolver.resolveArgument(paramHttpHeaders, null, this.exchange); + Object result = mono.block(); + + assertTrue(result instanceof HttpHeaders); + assertEquals("Invalid result", expected, result); + } + + + @SuppressWarnings("unused") + public void params(@RequestHeader Map param1, + @RequestHeader MultiValueMap param2, + @RequestHeader HttpHeaders param3, + Map unsupported) { + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestHeaderMethodArgumentResolverTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestHeaderMethodArgumentResolverTests.java new file mode 100644 index 0000000000..bda7710c5f --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestHeaderMethodArgumentResolverTests.java @@ -0,0 +1,230 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import java.lang.reflect.Method; +import java.net.URI; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.Date; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Mono; +import reactor.core.test.TestSubscriber; + +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.core.MethodParameter; +import org.springframework.core.annotation.SynthesizingMethodParameter; +import org.springframework.core.convert.ConversionService; +import org.springframework.format.support.DefaultFormattingConversionService; +import org.springframework.http.HttpMethod; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.util.ReflectionUtils; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.ServerWebInputException; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; +import org.springframework.web.server.session.WebSessionManager; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Unit tests for {@link RequestHeaderMethodArgumentResolver}. + * + * @author Rossen Stoyanchev + */ +public class RequestHeaderMethodArgumentResolverTests { + + private RequestHeaderMethodArgumentResolver resolver; + + private MethodParameter paramNamedDefaultValueStringHeader; + private MethodParameter paramNamedValueStringArray; + private MethodParameter paramSystemProperty; + private MethodParameter paramResolvedNameWithExpression; + private MethodParameter paramResolvedNameWithPlaceholder; + private MethodParameter paramNamedValueMap; + private MethodParameter paramDate; + private MethodParameter paramInstant; + + private ServerWebExchange exchange; + + + @Before + public void setUp() throws Exception { + ConversionService conversionService = new DefaultFormattingConversionService(); + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.refresh(); + this.resolver = new RequestHeaderMethodArgumentResolver(conversionService, context.getBeanFactory()); + + @SuppressWarnings("ConfusingArgumentToVarargsMethod") + Method method = ReflectionUtils.findMethod(getClass(), "params", (Class[]) null); + this.paramNamedDefaultValueStringHeader = new SynthesizingMethodParameter(method, 0); + this.paramNamedValueStringArray = new SynthesizingMethodParameter(method, 1); + this.paramSystemProperty = new SynthesizingMethodParameter(method, 2); + this.paramResolvedNameWithExpression = new SynthesizingMethodParameter(method, 3); + this.paramResolvedNameWithPlaceholder = new SynthesizingMethodParameter(method, 4); + this.paramNamedValueMap = new SynthesizingMethodParameter(method, 5); + this.paramDate = new SynthesizingMethodParameter(method, 6); + this.paramInstant = new SynthesizingMethodParameter(method, 7); + + ServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, new URI("/")); + WebSessionManager sessionManager = new MockWebSessionManager(); + this.exchange = new DefaultServerWebExchange(request, new MockServerHttpResponse(), sessionManager); + } + + + @Test + public void supportsParameter() { + assertTrue("String parameter not supported", resolver.supportsParameter(paramNamedDefaultValueStringHeader)); + assertTrue("String array parameter not supported", resolver.supportsParameter(paramNamedValueStringArray)); + assertFalse("non-@RequestParam parameter supported", resolver.supportsParameter(paramNamedValueMap)); + } + + @Test + public void resolveStringArgument() throws Exception { + String expected = "foo"; + this.exchange.getRequest().getHeaders().add("name", expected); + + Mono mono = this.resolver.resolveArgument(paramNamedDefaultValueStringHeader, null, this.exchange); + Object result = mono.block(); + assertTrue(result instanceof String); + assertEquals(expected, result); + } + + @Test + public void resolveStringArrayArgument() throws Exception { + String[] expected = new String[] {"foo", "bar"}; + this.exchange.getRequest().getHeaders().put("name", Arrays.asList(expected)); + + Mono mono = this.resolver.resolveArgument(paramNamedValueStringArray, null, this.exchange); + Object result = mono.block(); + assertTrue(result instanceof String[]); + assertArrayEquals(expected, (String[]) result); + } + + @Test + public void resolveDefaultValue() throws Exception { + Mono mono = this.resolver.resolveArgument(paramNamedDefaultValueStringHeader, null, this.exchange); + Object result = mono.block(); + assertTrue(result instanceof String); + assertEquals("bar", result); + } + + @Test + public void resolveDefaultValueFromSystemProperty() throws Exception { + System.setProperty("systemProperty", "bar"); + try { + Mono mono = this.resolver.resolveArgument(paramSystemProperty, null, this.exchange); + Object result = mono.block(); + assertTrue(result instanceof String); + assertEquals("bar", result); + } + finally { + System.clearProperty("systemProperty"); + } + } + + @Test + public void resolveNameFromSystemPropertyThroughExpression() throws Exception { + String expected = "foo"; + this.exchange.getRequest().getHeaders().add("bar", expected); + + System.setProperty("systemProperty", "bar"); + try { + Mono mono = this.resolver.resolveArgument(paramResolvedNameWithExpression, null, this.exchange); + Object result = mono.block(); + assertTrue(result instanceof String); + assertEquals(expected, result); + } + finally { + System.clearProperty("systemProperty"); + } + } + + @Test + public void resolveNameFromSystemPropertyThroughPlaceholder() throws Exception { + String expected = "foo"; + this.exchange.getRequest().getHeaders().add("bar", expected); + + System.setProperty("systemProperty", "bar"); + try { + Mono mono = this.resolver.resolveArgument(paramResolvedNameWithPlaceholder, null, this.exchange); + Object result = mono.block(); + assertTrue(result instanceof String); + assertEquals(expected, result); + } + finally { + System.clearProperty("systemProperty"); + } + } + + @Test + public void notFound() throws Exception { + Mono mono = resolver.resolveArgument(paramNamedValueStringArray, null, this.exchange); + TestSubscriber + .subscribe(mono) + .assertError(ServerWebInputException.class); + } + + @Test + @SuppressWarnings("deprecation") + public void dateConversion() throws Exception { + String rfc1123val = "Thu, 21 Apr 2016 17:11:08 +0100"; + this.exchange.getRequest().getHeaders().add("name", rfc1123val); + + Mono mono = this.resolver.resolveArgument(paramDate, null, this.exchange); + Object result = mono.block(); + + assertTrue(result instanceof Date); + assertEquals(new Date(rfc1123val), result); + } + + @Test + public void instantConversion() throws Exception { + String rfc1123val = "Thu, 21 Apr 2016 17:11:08 +0100"; + this.exchange.getRequest().getHeaders().add("name", rfc1123val); + + Mono mono = this.resolver.resolveArgument(paramInstant, null, this.exchange); + Object result = mono.block(); + + assertTrue(result instanceof Instant); + assertEquals(Instant.from(DateTimeFormatter.RFC_1123_DATE_TIME.parse(rfc1123val)), result); + } + + + @SuppressWarnings("unused") + public void params( + @RequestHeader(name = "name", defaultValue = "bar") String param1, + @RequestHeader("name") String[] param2, + @RequestHeader(name = "name", defaultValue="#{systemProperties.systemProperty}") String param3, + @RequestHeader("#{systemProperties.systemProperty}") String param4, + @RequestHeader("${systemProperty}") String param5, + @RequestHeader("name") Map unsupported, + @RequestHeader("name") Date dateParam, + @RequestHeader("name") Instant instantParam) { + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingExceptionHandlingIntegrationTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingExceptionHandlingIntegrationTests.java new file mode 100644 index 0000000000..c11d2d6ded --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingExceptionHandlingIntegrationTests.java @@ -0,0 +1,94 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import org.junit.Test; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Mono; + +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.reactive.config.WebReactiveConfiguration; + +import static org.junit.Assert.assertEquals; + + +/** + * {@code @RequestMapping} integration tests with exception handling scenarios. + * + * @author Rossen Stoyanchev + */ +public class RequestMappingExceptionHandlingIntegrationTests extends AbstractRequestMappingIntegrationTests { + + + @Override + protected ApplicationContext initApplicationContext() { + AnnotationConfigApplicationContext wac = new AnnotationConfigApplicationContext(); + wac.register(WebConfig.class); + wac.refresh(); + return wac; + } + + + @Test + public void controllerThrowingException() throws Exception { + String expected = "Recovered from error: Boo"; + assertEquals(expected, performGet("/thrown-exception", null, String.class).getBody()); + } + + @Test + public void controllerReturnsMonoError() throws Exception { + String expected = "Recovered from error: Boo"; + assertEquals(expected, performGet("/mono-error", null, String.class).getBody()); + } + + + @Configuration + @ComponentScan(resourcePattern = "**/RequestMappingExceptionHandlingIntegrationTests$*.class") + @SuppressWarnings({"unused", "WeakerAccess"}) + static class WebConfig extends WebReactiveConfiguration { + + } + + + @RestController + @SuppressWarnings("unused") + private static class TestController { + + @GetMapping("/thrown-exception") + public Publisher handleAndThrowException() { + throw new IllegalStateException("Boo"); + } + + @GetMapping("/mono-error") + public Publisher handleWithError() { + return Mono.error(new IllegalStateException("Boo")); + } + + @ExceptionHandler + public Publisher handleException(IllegalStateException ex) { + return Mono.just("Recovered from error: " + ex.getMessage()); + } + + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMappingTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMappingTests.java new file mode 100644 index 0000000000..8635b6f9eb --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMappingTests.java @@ -0,0 +1,247 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import org.junit.Before; +import org.junit.Test; + +import org.springframework.core.annotation.AliasFor; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.context.support.StaticWebApplicationContext; +import org.springframework.web.reactive.accept.MappingContentTypeResolver; +import org.springframework.web.reactive.result.method.RequestMappingInfo; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link RequestMappingHandlerMapping}. + * + * @author Rossen Stoyanchev + */ +public class RequestMappingHandlerMappingTests { + + private final StaticWebApplicationContext wac = new StaticWebApplicationContext(); + + private final RequestMappingHandlerMapping handlerMapping = new RequestMappingHandlerMapping(); + + + @Before + public void setUp() throws Exception { + this.handlerMapping.setApplicationContext(wac); + } + + + @Test + public void useRegisteredSuffixPatternMatch() { + assertTrue(this.handlerMapping.useSuffixPatternMatch()); + assertTrue(this.handlerMapping.useRegisteredSuffixPatternMatch()); + + MappingContentTypeResolver contentTypeResolver = mock(MappingContentTypeResolver.class); + when(contentTypeResolver.getKeys()).thenReturn(Collections.singleton("json")); + + this.handlerMapping.setContentTypeResolver(contentTypeResolver); + this.handlerMapping.afterPropertiesSet(); + + assertTrue(this.handlerMapping.useSuffixPatternMatch()); + assertTrue(this.handlerMapping.useRegisteredSuffixPatternMatch()); + assertEquals(Collections.singleton("json"), this.handlerMapping.getFileExtensions()); + } + + @Test + public void useRegisteredSuffixPatternMatchInitialization() { + MappingContentTypeResolver contentTypeResolver = mock(MappingContentTypeResolver.class); + when(contentTypeResolver.getKeys()).thenReturn(Collections.singleton("json")); + + final Set actualExtensions = new HashSet<>(); + RequestMappingHandlerMapping localHandlerMapping = new RequestMappingHandlerMapping() { + @Override + protected RequestMappingInfo getMappingForMethod(Method method, Class handlerType) { + actualExtensions.addAll(getFileExtensions()); + return super.getMappingForMethod(method, handlerType); + } + }; + this.wac.registerSingleton("testController", ComposedAnnotationController.class); + this.wac.refresh(); + + localHandlerMapping.setContentTypeResolver(contentTypeResolver); + localHandlerMapping.setUseRegisteredSuffixPatternMatch(true); + localHandlerMapping.setApplicationContext(this.wac); + localHandlerMapping.afterPropertiesSet(); + + assertEquals(Collections.singleton("json"), actualExtensions); + } + + @Test + public void useSuffixPatternMatch() { + assertTrue(this.handlerMapping.useSuffixPatternMatch()); + assertTrue(this.handlerMapping.useRegisteredSuffixPatternMatch()); + + this.handlerMapping.setUseSuffixPatternMatch(false); + assertFalse(this.handlerMapping.useSuffixPatternMatch()); + + this.handlerMapping.setUseRegisteredSuffixPatternMatch(false); + assertFalse("'false' registeredSuffixPatternMatch shouldn't impact suffixPatternMatch", + this.handlerMapping.useSuffixPatternMatch()); + + this.handlerMapping.setUseRegisteredSuffixPatternMatch(true); + assertTrue("'true' registeredSuffixPatternMatch should enable suffixPatternMatch", + this.handlerMapping.useSuffixPatternMatch()); + } + + @Test + public void resolveEmbeddedValuesInPatterns() { + this.handlerMapping.setEmbeddedValueResolver( + value -> "/${pattern}/bar".equals(value) ? "/foo/bar" : value + ); + + String[] patterns = new String[] { "/foo", "/${pattern}/bar" }; + String[] result = this.handlerMapping.resolveEmbeddedValuesInPatterns(patterns); + + assertArrayEquals(new String[] { "/foo", "/foo/bar" }, result); + } + + @Test + public void resolveRequestMappingViaComposedAnnotation() throws Exception { + RequestMappingInfo info = assertComposedAnnotationMapping("postJson", "/postJson", RequestMethod.POST); + + assertEquals(MediaType.APPLICATION_JSON_VALUE, + info.getConsumesCondition().getConsumableMediaTypes().iterator().next().toString()); + assertEquals(MediaType.APPLICATION_JSON_VALUE, + info.getProducesCondition().getProducibleMediaTypes().iterator().next().toString()); + } + + @Test + public void getMapping() throws Exception { + assertComposedAnnotationMapping(RequestMethod.GET); + } + + @Test + public void postMapping() throws Exception { + assertComposedAnnotationMapping(RequestMethod.POST); + } + + @Test + public void putMapping() throws Exception { + assertComposedAnnotationMapping(RequestMethod.PUT); + } + + @Test + public void deleteMapping() throws Exception { + assertComposedAnnotationMapping(RequestMethod.DELETE); + } + + @Test + public void patchMapping() throws Exception { + assertComposedAnnotationMapping(RequestMethod.PATCH); + } + + private RequestMappingInfo assertComposedAnnotationMapping(RequestMethod requestMethod) throws Exception { + String methodName = requestMethod.name().toLowerCase(); + String path = "/" + methodName; + + return assertComposedAnnotationMapping(methodName, path, requestMethod); + } + + private RequestMappingInfo assertComposedAnnotationMapping(String methodName, String path, + RequestMethod requestMethod) throws Exception { + + Class clazz = ComposedAnnotationController.class; + Method method = clazz.getMethod(methodName); + RequestMappingInfo info = this.handlerMapping.getMappingForMethod(method, clazz); + + assertNotNull(info); + + Set paths = info.getPatternsCondition().getPatterns(); + assertEquals(1, paths.size()); + assertEquals(path, paths.iterator().next()); + + Set methods = info.getMethodsCondition().getMethods(); + assertEquals(1, methods.size()); + assertEquals(requestMethod, methods.iterator().next()); + + return info; + } + + + @Controller @SuppressWarnings("unused") + static class ComposedAnnotationController { + + @RequestMapping + public void handle() { + } + + @PostJson("/postJson") + public void postJson() { + } + + @GetMapping("/get") + public void get() { + } + + @PostMapping("/post") + public void post() { + } + + @PutMapping("/put") + public void put() { + } + + @DeleteMapping("/delete") + public void delete() { + } + + @PatchMapping("/patch") + public void patch() { + } + + } + + @RequestMapping(method = RequestMethod.POST, + produces = MediaType.APPLICATION_JSON_VALUE, + consumes = MediaType.APPLICATION_JSON_VALUE) + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.RUNTIME) + @interface PostJson { + + @AliasFor(annotation = RequestMapping.class, attribute = "path") @SuppressWarnings("unused") + String[] value() default {}; + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingIntegrationTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingIntegrationTests.java new file mode 100644 index 0000000000..b5fc3627b2 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingIntegrationTests.java @@ -0,0 +1,90 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import org.junit.Test; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; + +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.reactive.config.WebReactiveConfiguration; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; + + +/** + * Integration tests with {@code @RequestMapping} handler methods. + * + *

Before adding tests here consider if they are a better fit for any of the + * other {@code RequestMapping*IntegrationTests}. + * + * @author Rossen Stoyanchev + * @author Stephane Maldini + */ +public class RequestMappingIntegrationTests extends AbstractRequestMappingIntegrationTests { + + @Override + protected ApplicationContext initApplicationContext() { + AnnotationConfigApplicationContext wac = new AnnotationConfigApplicationContext(); + wac.register(WebConfig.class); + wac.refresh(); + return wac; + } + + @Test + public void handleWithParam() throws Exception { + String expected = "Hello George!"; + assertEquals(expected, performGet("/param?name=George", null, String.class).getBody()); + } + + @Test + public void streamResult() throws Exception { + String[] expected = {"0", "1", "2", "3", "4"}; + assertArrayEquals(expected, performGet("/stream-result", null, String[].class).getBody()); + } + + + @Configuration + @ComponentScan(resourcePattern = "**/RequestMappingIntegrationTests$*.class") + @SuppressWarnings({"unused", "WeakerAccess"}) + static class WebConfig extends WebReactiveConfiguration { + } + + @RestController + @SuppressWarnings("unused") + private static class TestRestController { + + @GetMapping("/param") + public Publisher handleWithParam(@RequestParam String name) { + return Flux.just("Hello ", name, "!"); + } + + @GetMapping("/stream-result") + public Publisher stringStreamResponseBody() { + return Flux.intervalMillis(100).take(5); + } + + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingMessageConversionIntegrationTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingMessageConversionIntegrationTests.java new file mode 100644 index 0000000000..7991dd14e6 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingMessageConversionIntegrationTests.java @@ -0,0 +1,499 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.XmlRootElement; + +import org.junit.Test; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import rx.Observable; +import rx.Single; + +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.ResolvableType; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.codec.json.JacksonJsonEncoder; +import org.springframework.http.server.reactive.ZeroCopyIntegrationTests; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.reactive.config.WebReactiveConfiguration; + +import static java.util.Arrays.asList; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.springframework.http.MediaType.APPLICATION_XML; + + +/** + * {@code @RequestMapping} integration tests focusing on serialization and + * deserialization of the request and response body. + * + * @author Rossen Stoyanchev + * @author Sebastien Deleuze + */ +public class RequestMappingMessageConversionIntegrationTests extends AbstractRequestMappingIntegrationTests { + + private static final ParameterizedTypeReference> PERSON_LIST = + new ParameterizedTypeReference>() {}; + + private static final MediaType JSON = MediaType.APPLICATION_JSON; + + + @Override + protected ApplicationContext initApplicationContext() { + AnnotationConfigApplicationContext wac = new AnnotationConfigApplicationContext(); + wac.register(WebConfig.class); + wac.refresh(); + return wac; + } + + + @Test + public void byteBufferResponseBodyWithPublisher() throws Exception { + Person expected = new Person("Robert"); + assertEquals(expected, performGet("/raw-response/publisher", JSON, Person.class).getBody()); + } + + @Test + public void byteBufferResponseBodyWithFlux() throws Exception { + String expected = "Hello!"; + assertEquals(expected, performGet("/raw-response/flux", null, String.class).getBody()); + } + + @Test + public void byteBufferResponseBodyWithObservable() throws Exception { + String expected = "Hello!"; + assertEquals(expected, performGet("/raw-response/observable", null, String.class).getBody()); + } + + @Test + public void personResponseBody() throws Exception { + Person expected = new Person("Robert"); + assertEquals(expected, performGet("/person-response/person", JSON, Person.class).getBody()); + } + + @Test + public void personResponseBodyWithCompletableFuture() throws Exception { + Person expected = new Person("Robert"); + assertEquals(expected, performGet("/person-response/completable-future", JSON, Person.class).getBody()); + } + + @Test + public void personResponseBodyWithMono() throws Exception { + Person expected = new Person("Robert"); + assertEquals(expected, performGet("/person-response/mono", JSON, Person.class).getBody()); + } + + @Test + public void personResponseBodyWithSingle() throws Exception { + Person expected = new Person("Robert"); + assertEquals(expected, performGet("/person-response/single", JSON, Person.class).getBody()); + } + + @Test + public void personResponseBodyWithMonoResponseEntity() throws Exception { + Person expected = new Person("Robert"); + assertEquals(expected, performGet("/person-response/mono-response-entity", JSON, Person.class).getBody()); + } + + @Test + public void personResponseBodyWithList() throws Exception { + List expected = asList(new Person("Robert"), new Person("Marie")); + assertEquals(expected, performGet("/person-response/list", JSON, PERSON_LIST).getBody()); + } + + @Test + public void personResponseBodyWithPublisher() throws Exception { + List expected = asList(new Person("Robert"), new Person("Marie")); + assertEquals(expected, performGet("/person-response/publisher", JSON, PERSON_LIST).getBody()); + } + + @Test + public void personResponseBodyWithFlux() throws Exception { + List expected = asList(new Person("Robert"), new Person("Marie")); + assertEquals(expected, performGet("/person-response/flux", JSON, PERSON_LIST).getBody()); + } + + @Test + public void personResponseBodyWithObservable() throws Exception { + List expected = asList(new Person("Robert"), new Person("Marie")); + assertEquals(expected, performGet("/person-response/observable", JSON, PERSON_LIST).getBody()); + } + + @Test + public void resource() throws Exception { + ResponseEntity response = performGet("/resource", null, byte[].class); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertTrue(response.hasBody()); + assertEquals(951, response.getHeaders().getContentLength()); + assertEquals(951, response.getBody().length); + assertEquals(new MediaType("image", "x-png"), response.getHeaders().getContentType()); + } + + @Test + public void personTransform() throws Exception { + assertEquals(new Person("ROBERT"), + performPost("/person-transform/person", JSON, new Person("Robert"), + JSON, Person.class).getBody()); + } + + @Test + public void personTransformWithCompletableFuture() throws Exception { + assertEquals(new Person("ROBERT"), + performPost("/person-transform/completable-future", JSON, new Person("Robert"), + JSON, Person.class).getBody()); + } + + @Test + public void personTransformWithMono() throws Exception { + assertEquals(new Person("ROBERT"), + performPost("/person-transform/mono", JSON, new Person("Robert"), + JSON, Person.class).getBody()); + } + + @Test + public void personTransformWithSingle() throws Exception { + assertEquals(new Person("ROBERT"), + performPost("/person-transform/single", JSON, new Person("Robert"), + JSON, Person.class).getBody()); + } + + @Test + public void personTransformWithPublisher() throws Exception { + List req = asList(new Person("Robert"), new Person("Marie")); + List res = asList(new Person("ROBERT"), new Person("MARIE")); + assertEquals(res, performPost("/person-transform/publisher", JSON, req, JSON, PERSON_LIST).getBody()); + } + + @Test + public void personTransformWithFlux() throws Exception { + List req = asList(new Person("Robert"), new Person("Marie")); + List res = asList(new Person("ROBERT"), new Person("MARIE")); + assertEquals(res, performPost("/person-transform/flux", JSON, req, JSON, PERSON_LIST).getBody()); + } + + @Test + public void personTransformWithObservable() throws Exception { + List req = asList(new Person("Robert"), new Person("Marie")); + List res = asList(new Person("ROBERT"), new Person("MARIE")); + assertEquals(res, performPost("/person-transform/observable", JSON, req, JSON, PERSON_LIST).getBody()); + } + + @Test + public void personCreateWithPublisherJson() throws Exception { + ResponseEntity entity = performPost("/person-create/publisher", JSON, + asList(new Person("Robert"), new Person("Marie")), null, Void.class); + + assertEquals(HttpStatus.OK, entity.getStatusCode()); + assertEquals(2, getApplicationContext().getBean(PersonCreateController.class).persons.size()); + } + + @Test + public void personCreateWithPublisherXml() throws Exception { + People people = new People(new Person("Robert"), new Person("Marie")); + ResponseEntity response = performPost("/person-create/publisher", APPLICATION_XML, people, null, Void.class); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(2, getApplicationContext().getBean(PersonCreateController.class).persons.size()); + } + + @Test + public void personCreateWithFluxJson() throws Exception { + ResponseEntity entity = performPost("/person-create/flux", JSON, + asList(new Person("Robert"), new Person("Marie")), null, Void.class); + + assertEquals(HttpStatus.OK, entity.getStatusCode()); + assertEquals(2, getApplicationContext().getBean(PersonCreateController.class).persons.size()); + } + + @Test + public void personCreateWithFluxXml() throws Exception { + People people = new People(new Person("Robert"), new Person("Marie")); + ResponseEntity response = performPost("/person-create/flux", APPLICATION_XML, people, null, Void.class); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(2, getApplicationContext().getBean(PersonCreateController.class).persons.size()); + } + + @Test + public void personCreateWithObservableJson() throws Exception { + ResponseEntity entity = performPost("/person-create/observable", JSON, + asList(new Person("Robert"), new Person("Marie")), null, Void.class); + + assertEquals(HttpStatus.OK, entity.getStatusCode()); + assertEquals(2, getApplicationContext().getBean(PersonCreateController.class).persons.size()); + } + + @Test + public void personCreateWithObservableXml() throws Exception { + People people = new People(new Person("Robert"), new Person("Marie")); + ResponseEntity response = performPost("/person-create/observable", APPLICATION_XML, people, null, Void.class); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(2, getApplicationContext().getBean(PersonCreateController.class).persons.size()); + } + + + @Configuration + @ComponentScan(resourcePattern = "**/RequestMappingMessageConversionIntegrationTests$*.class") + @SuppressWarnings({"unused", "WeakerAccess"}) + static class WebConfig extends WebReactiveConfiguration { + } + + + @RestController + @RequestMapping("/raw-response") + @SuppressWarnings("unused") + private static class RawResponseBodyController { + + @GetMapping("/publisher") + public Publisher getPublisher() { + DataBufferFactory dataBufferFactory = new DefaultDataBufferFactory(); + JacksonJsonEncoder encoder = new JacksonJsonEncoder(); + return encoder.encode(Mono.just(new Person("Robert")), dataBufferFactory, + ResolvableType.forClass(Person.class), JSON).map(DataBuffer::asByteBuffer); + } + + @GetMapping("/flux") + public Flux getFlux() { + return Flux.just(ByteBuffer.wrap("Hello!".getBytes())); + } + + @GetMapping("/observable") + public Observable getObservable() { + return Observable.just(ByteBuffer.wrap("Hello!".getBytes())); + } + } + + @RestController + @RequestMapping("/person-response") + @SuppressWarnings("unused") + private static class PersonResponseBodyController { + + @GetMapping("/person") + public Person getPerson() { + return new Person("Robert"); + } + + @GetMapping("/completable-future") + public CompletableFuture getCompletableFuture() { + return CompletableFuture.completedFuture(new Person("Robert")); + } + + @GetMapping("/mono") + public Mono getMono() { + return Mono.just(new Person("Robert")); + } + + @GetMapping("/single") + public Single getSingle() { + return Single.just(new Person("Robert")); + } + + @GetMapping("/mono-response-entity") + public ResponseEntity> getMonoResponseEntity() { + Mono body = Mono.just(new Person("Robert")); + return ResponseEntity.ok(body); + } + + @GetMapping("/list") + public List getList() { + return asList(new Person("Robert"), new Person("Marie")); + } + + @GetMapping("/publisher") + public Publisher getPublisher() { + return Flux.just(new Person("Robert"), new Person("Marie")); + } + + @GetMapping("/flux") + public Flux getFlux() { + return Flux.just(new Person("Robert"), new Person("Marie")); + } + + @GetMapping("/observable") + public Observable getObservable() { + return Observable.just(new Person("Robert"), new Person("Marie")); + } + } + + @RestController + @SuppressWarnings("unused") + private static class ResourceController { + + @GetMapping("/resource") + public Resource resource() { + return new ClassPathResource("spring.png", ZeroCopyIntegrationTests.class); + } + } + + @RestController + @RequestMapping("/person-transform") + @SuppressWarnings("unused") + private static class PersonTransformationController { + + @PostMapping("/person") + public Person transformPerson(@RequestBody Person person) { + return new Person(person.getName().toUpperCase()); + } + + @PostMapping("/completable-future") + public CompletableFuture transformCompletableFuture( + @RequestBody CompletableFuture personFuture) { + return personFuture.thenApply(person -> new Person(person.getName().toUpperCase())); + } + + @PostMapping("/mono") + public Mono transformMono(@RequestBody Mono personFuture) { + return personFuture.map(person -> new Person(person.getName().toUpperCase())); + } + + @PostMapping("/single") + public Single transformSingle(@RequestBody Single personFuture) { + return personFuture.map(person -> new Person(person.getName().toUpperCase())); + } + + @PostMapping("/publisher") + public Publisher transformPublisher(@RequestBody Publisher persons) { + return Flux + .from(persons) + .map(person -> new Person(person.getName().toUpperCase())); + } + + @PostMapping("/flux") + public Flux transformFlux(@RequestBody Flux persons) { + return persons.map(person -> new Person(person.getName().toUpperCase())); + } + + @PostMapping("/observable") + public Observable transformObservable(@RequestBody Observable persons) { + return persons.map(person -> new Person(person.getName().toUpperCase())); + } + } + + @RestController + @RequestMapping("/person-create") + @SuppressWarnings("unused") + private static class PersonCreateController { + + final List persons = new ArrayList<>(); + + @PostMapping("/publisher") + public Publisher createWithPublisher(@RequestBody Publisher personStream) { + return Flux.from(personStream).doOnNext(persons::add).then(); + } + + @PostMapping("/flux") + public Mono createWithFlux(@RequestBody Flux personStream) { + return personStream.doOnNext(persons::add).then(); + } + + @PostMapping("/observable") + public Observable createWithObservable(@RequestBody Observable personStream) { + return personStream.toList().doOnNext(persons::addAll).flatMap(document -> Observable.empty()); + } + } + + @XmlRootElement @SuppressWarnings("WeakerAccess") + private static class Person { + + private String name; + + @SuppressWarnings("unused") + public Person() { + } + + public Person(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Person person = (Person) o; + return !(this.name != null ? !this.name.equals(person.name) : person.name != null); + } + + @Override + public int hashCode() { + return this.name != null ? this.name.hashCode() : 0; + } + + @Override + public String toString() { + return "Person{" + + "name='" + name + '\'' + + '}'; + } + } + + @XmlRootElement @SuppressWarnings({"WeakerAccess", "unused"}) + private static class People { + + private List persons = new ArrayList<>(); + + public People() { + } + + public People(Person... persons) { + this.persons.addAll(Arrays.asList(persons)); + } + + @XmlElement + public List getPerson() { + return this.persons; + } + + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingViewResolutionIntegrationTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingViewResolutionIntegrationTests.java new file mode 100644 index 0000000000..9ea276300a --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingViewResolutionIntegrationTests.java @@ -0,0 +1,93 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import org.junit.Test; + +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.reactive.config.ViewResolverRegistry; +import org.springframework.web.reactive.config.WebReactiveConfiguration; +import org.springframework.web.reactive.result.view.freemarker.FreeMarkerConfigurer; + +import static org.junit.Assert.assertEquals; + + +/** + * {@code @RequestMapping} integration tests with view resolution scenarios. + * + * @author Rossen Stoyanchev + */ +public class RequestMappingViewResolutionIntegrationTests extends AbstractRequestMappingIntegrationTests { + + + @Override + protected ApplicationContext initApplicationContext() { + AnnotationConfigApplicationContext wac = new AnnotationConfigApplicationContext(); + wac.register(WebConfig.class); + wac.refresh(); + return wac; + } + + + @Test + public void html() throws Exception { + String expected = "Hello: Jason!"; + assertEquals(expected, performGet("/html?name=Jason", MediaType.TEXT_HTML, String.class).getBody()); + } + + + @Configuration + @ComponentScan(resourcePattern = "**/RequestMappingViewResolutionIntegrationTests$*.class") + @SuppressWarnings({"unused", "WeakerAccess"}) + static class WebConfig extends WebReactiveConfiguration { + + @Override + protected void configureViewResolvers(ViewResolverRegistry registry) { + registry.freeMarker(); + } + + @Bean + public FreeMarkerConfigurer freeMarkerConfig() { + FreeMarkerConfigurer configurer = new FreeMarkerConfigurer(); + configurer.setPreferFileSystemAccess(false); + configurer.setTemplateLoaderPath("classpath*:org/springframework/web/reactive/view/freemarker/"); + return configurer; + } + + } + + @Controller + @SuppressWarnings("unused") + private static class TestController { + + @GetMapping("/html") + public String getHtmlPage(@RequestParam String name, Model model) { + model.addAttribute("hello", "Hello: " + name + "!"); + return "test"; + } + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestParamMapMethodArgumentResolverTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestParamMapMethodArgumentResolverTests.java new file mode 100644 index 0000000000..c9d7afca9b --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestParamMapMethodArgumentResolverTests.java @@ -0,0 +1,129 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import java.lang.reflect.Method; +import java.net.URI; +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.core.annotation.SynthesizingMethodParameter; +import org.springframework.http.HttpMethod; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; +import org.springframework.web.server.session.WebSessionManager; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Unit tests for {@link RequestParamMapMethodArgumentResolver}. + * + * @author Rossen Stoyanchev + */ +public class RequestParamMapMethodArgumentResolverTests { + + private RequestParamMapMethodArgumentResolver resolver; + + private ServerWebExchange exchange; + + private MethodParameter paramMap; + private MethodParameter paramMultiValueMap; + private MethodParameter paramNamedMap; + private MethodParameter paramMapWithoutAnnot; + + + + @Before + public void setUp() throws Exception { + this.resolver = new RequestParamMapMethodArgumentResolver(); + + ServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, new URI("/")); + WebSessionManager sessionManager = new MockWebSessionManager(); + this.exchange = new DefaultServerWebExchange(request, new MockServerHttpResponse(), sessionManager); + + Method method = getClass().getMethod("params", Map.class, MultiValueMap.class, Map.class, Map.class); + this.paramMap = new SynthesizingMethodParameter(method, 0); + this.paramMultiValueMap = new SynthesizingMethodParameter(method, 1); + this.paramNamedMap = new SynthesizingMethodParameter(method, 2); + this.paramMapWithoutAnnot = new SynthesizingMethodParameter(method, 3); + } + + + @Test + public void supportsParameter() { + assertTrue(this.resolver.supportsParameter(this.paramMap)); + assertTrue(this.resolver.supportsParameter(this.paramMultiValueMap)); + assertFalse(this.resolver.supportsParameter(this.paramNamedMap)); + assertFalse(this.resolver.supportsParameter(this.paramMapWithoutAnnot)); + } + + @Test + public void resolveMapArgument() throws Exception { + String name = "foo"; + String value = "bar"; + this.exchange.getRequest().getQueryParams().set(name, value); + Map expected = Collections.singletonMap(name, value); + + Mono mono = resolver.resolveArgument(paramMap, null, exchange); + Object result = mono.block(); + + assertTrue(result instanceof Map); + assertEquals(expected, result); + } + + @Test + public void resolveMultiValueMapArgument() throws Exception { + String name = "foo"; + String value1 = "bar"; + String value2 = "baz"; + this.exchange.getRequest().getQueryParams().put(name, Arrays.asList(value1, value2)); + + MultiValueMap expected = new LinkedMultiValueMap<>(1); + expected.add(name, value1); + expected.add(name, value2); + + Mono mono = this.resolver.resolveArgument(this.paramMultiValueMap, null, this.exchange); + Object result = mono.block(); + + assertTrue(result instanceof MultiValueMap); + assertEquals(expected, result); + } + + + @SuppressWarnings("unused") + public void params(@RequestParam Map param1, + @RequestParam MultiValueMap param2, + @RequestParam("name") Map param3, + Map param4) { + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestParamMethodArgumentResolverTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestParamMethodArgumentResolverTests.java new file mode 100644 index 0000000000..bb6a23f096 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestParamMethodArgumentResolverTests.java @@ -0,0 +1,232 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import java.lang.reflect.Method; +import java.net.URI; +import java.util.Arrays; +import java.util.Map; +import java.util.Optional; + +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Mono; +import reactor.core.test.TestSubscriber; + +import org.springframework.core.LocalVariableTableParameterNameDiscoverer; +import org.springframework.core.MethodParameter; +import org.springframework.core.ParameterNameDiscoverer; +import org.springframework.core.annotation.SynthesizingMethodParameter; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.core.convert.support.GenericConversionService; +import org.springframework.http.HttpMethod; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.util.ReflectionUtils; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.ServerWebInputException; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; +import org.springframework.web.server.session.WebSessionManager; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * Unit tests for {@link RequestParamMethodArgumentResolver}. + * + * @author Rossen Stoyanchev + */ +public class RequestParamMethodArgumentResolverTests { + + private RequestParamMethodArgumentResolver resolver; + + private ServerWebExchange exchange; + + private MethodParameter paramNamedDefaultValueString; + private MethodParameter paramNamedStringArray; + private MethodParameter paramNamedMap; + private MethodParameter paramMap; + private MethodParameter paramStringNotAnnot; + private MethodParameter paramRequired; + private MethodParameter paramNotRequired; + private MethodParameter paramOptional; + + + @Before @SuppressWarnings("ConfusingArgumentToVarargsMethod") + public void setUp() throws Exception { + ConversionService conversionService = new DefaultConversionService(); + this.resolver = new RequestParamMethodArgumentResolver(conversionService, null, true); + + ParameterNameDiscoverer paramNameDiscoverer = new LocalVariableTableParameterNameDiscoverer(); + Method method = ReflectionUtils.findMethod(getClass(), "handle", (Class[]) null); + + ServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, new URI("/")); + WebSessionManager sessionManager = new MockWebSessionManager(); + this.exchange = new DefaultServerWebExchange(request, new MockServerHttpResponse(), sessionManager); + + this.paramNamedDefaultValueString = new SynthesizingMethodParameter(method, 0); + this.paramNamedStringArray = new SynthesizingMethodParameter(method, 1); + this.paramNamedMap = new SynthesizingMethodParameter(method, 2); + this.paramMap = new SynthesizingMethodParameter(method, 3); + this.paramStringNotAnnot = new SynthesizingMethodParameter(method, 4); + this.paramStringNotAnnot.initParameterNameDiscovery(paramNameDiscoverer); + this.paramRequired = new SynthesizingMethodParameter(method, 5); + this.paramNotRequired = new SynthesizingMethodParameter(method, 6); + this.paramOptional = new SynthesizingMethodParameter(method, 7); + } + + + @Test + public void supportsParameter() { + this.resolver = new RequestParamMethodArgumentResolver(new GenericConversionService(), null, true); + assertTrue(this.resolver.supportsParameter(this.paramNamedDefaultValueString)); + assertTrue(this.resolver.supportsParameter(this.paramNamedStringArray)); + assertTrue(this.resolver.supportsParameter(this.paramNamedMap)); + assertFalse(this.resolver.supportsParameter(this.paramMap)); + assertTrue(this.resolver.supportsParameter(this.paramStringNotAnnot)); + assertTrue(this.resolver.supportsParameter(this.paramRequired)); + assertTrue(this.resolver.supportsParameter(this.paramNotRequired)); + assertTrue(this.resolver.supportsParameter(this.paramOptional)); + + this.resolver = new RequestParamMethodArgumentResolver(new GenericConversionService(), null, false); + assertFalse(this.resolver.supportsParameter(this.paramStringNotAnnot)); + } + + @Test + public void resolveString() throws Exception { + String expected = "foo"; + this.exchange.getRequest().getQueryParams().set("name", expected); + + Mono mono = this.resolver.resolveArgument(this.paramNamedDefaultValueString, null, this.exchange); + Object result = mono.block(); + + assertTrue(result instanceof String); + assertEquals("Invalid result", expected, result); + } + + @Test + public void resolveStringArray() throws Exception { + String[] expected = {"foo", "bar"}; + this.exchange.getRequest().getQueryParams().put("name", Arrays.asList(expected)); + + Mono mono = this.resolver.resolveArgument(this.paramNamedStringArray, null, this.exchange); + Object result = mono.block(); + + assertTrue(result instanceof String[]); + assertArrayEquals(expected, (String[]) result); + } + + @Test + public void resolveDefaultValue() throws Exception { + Mono mono = this.resolver.resolveArgument(paramNamedDefaultValueString, null, this.exchange); + Object result = mono.block(); + + assertTrue(result instanceof String); + assertEquals("Invalid result", "bar", result); + } + + @Test + public void missingRequestParam() throws Exception { + Mono mono = this.resolver.resolveArgument(paramNamedStringArray, null, this.exchange); + TestSubscriber + .subscribe(mono) + .assertError(ServerWebInputException.class); + } + + @Test + public void resolveSimpleTypeParam() throws Exception { + this.exchange.getRequest().getQueryParams().set("stringNotAnnot", "plainValue"); + Mono mono = this.resolver.resolveArgument(paramStringNotAnnot, null, this.exchange); + Object result = mono.block(); + + assertTrue(result instanceof String); + assertEquals("plainValue", result); + } + + @Test // SPR-8561 + public void resolveSimpleTypeParamToNull() throws Exception { + Mono mono = this.resolver.resolveArgument(paramStringNotAnnot, null, this.exchange); + Object result = mono.block(); + + assertNull(result); + } + + @Test // SPR-10180 + public void resolveEmptyValueToDefault() throws Exception { + this.exchange.getRequest().getQueryParams().set("name", ""); + Mono mono = this.resolver.resolveArgument(paramNamedDefaultValueString, null, this.exchange); + Object result = mono.block(); + + assertEquals("bar", result); + } + + @Test + public void resolveEmptyValueWithoutDefault() throws Exception { + this.exchange.getRequest().getQueryParams().set("stringNotAnnot", ""); + Mono mono = this.resolver.resolveArgument(paramStringNotAnnot, null, this.exchange); + Object result = mono.block(); + + assertEquals("", result); + } + + @Test + public void resolveEmptyValueRequiredWithoutDefault() throws Exception { + this.exchange.getRequest().getQueryParams().set("name", ""); + Mono mono = this.resolver.resolveArgument(paramRequired, null, this.exchange); + Object result = mono.block(); + + assertEquals("", result); + } + + @Test + public void resolveOptionalParamValue() throws Exception { + Mono mono = this.resolver.resolveArgument(paramOptional, null, this.exchange); + Object result = mono.block(); + + assertEquals(Optional.empty(), result); + + this.exchange.getRequest().getQueryParams().set("name", "123"); + mono = resolver.resolveArgument(paramOptional, null, this.exchange); + result = mono.block(); + + assertEquals(Optional.class, result.getClass()); + Optional value = (Optional) result; + assertTrue(value.isPresent()); + assertEquals(123, value.get()); + } + + + @SuppressWarnings({"unused", "OptionalUsedAsFieldOrParameterType"}) + public void handle( + @RequestParam(name = "name", defaultValue = "bar") String param1, + @RequestParam("name") String[] param2, + @RequestParam("name") Map param3, + @RequestParam Map param4, + String stringNotAnnot, + @RequestParam("name") String paramRequired, + @RequestParam(name = "name", required = false) String paramNotRequired, + @RequestParam("name") Optional paramOptional) { + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseBodyResultHandlerTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseBodyResultHandlerTests.java new file mode 100644 index 0000000000..cfd747d5a3 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseBodyResultHandlerTests.java @@ -0,0 +1,169 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Mono; + +import org.springframework.core.codec.ByteBufferEncoder; +import org.springframework.core.codec.StringEncoder; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.core.convert.support.MonoToCompletableFutureConverter; +import org.springframework.core.convert.support.ReactorToRxJava1Converter; +import org.springframework.format.support.DefaultFormattingConversionService; +import org.springframework.format.support.FormattingConversionService; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.http.codec.json.JacksonJsonEncoder; +import org.springframework.http.codec.xml.Jaxb2Encoder; +import org.springframework.http.converter.reactive.CodecHttpMessageConverter; +import org.springframework.http.converter.reactive.HttpMessageConverter; +import org.springframework.http.converter.reactive.ResourceHttpMessageConverter; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.stereotype.Controller; +import org.springframework.ui.ExtendedModelMap; +import org.springframework.util.ObjectUtils; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.reactive.HandlerResult; +import org.springframework.web.reactive.accept.RequestedContentTypeResolver; +import org.springframework.web.reactive.accept.RequestedContentTypeResolverBuilder; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; + +import static org.junit.Assert.assertEquals; + + +/** + * Unit tests for {@link ResponseBodyResultHandler}.When adding a test also + * consider whether the logic under test is in a parent class, then see: + *
    + *
  • {@code MessageConverterResultHandlerTests}, + *
  • {@code ContentNegotiatingResultHandlerSupportTests} + *
+ * + * @author Sebastien Deleuze + * @author Rossen Stoyanchev + */ +public class ResponseBodyResultHandlerTests { + + private ResponseBodyResultHandler resultHandler; + + private MockServerHttpResponse response = new MockServerHttpResponse(); + + private ServerWebExchange exchange; + + + @Before + public void setUp() throws Exception { + this.resultHandler = createHandler(); + ServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, new URI("/path")); + this.exchange = new DefaultServerWebExchange(request, this.response, new MockWebSessionManager()); + } + + + private ResponseBodyResultHandler createHandler(HttpMessageConverter... converters) { + List> converterList; + if (ObjectUtils.isEmpty(converters)) { + converterList = new ArrayList<>(); + converterList.add(new CodecHttpMessageConverter<>(new ByteBufferEncoder())); + converterList.add(new CodecHttpMessageConverter<>(new StringEncoder())); + converterList.add(new ResourceHttpMessageConverter()); + converterList.add(new CodecHttpMessageConverter<>(new Jaxb2Encoder())); + converterList.add(new CodecHttpMessageConverter<>(new JacksonJsonEncoder())); + } + else { + converterList = Arrays.asList(converters); + } + FormattingConversionService service = new DefaultFormattingConversionService(); + service.addConverter(new MonoToCompletableFutureConverter()); + service.addConverter(new ReactorToRxJava1Converter()); + RequestedContentTypeResolver resolver = new RequestedContentTypeResolverBuilder().build(); + + return new ResponseBodyResultHandler(converterList, new DefaultConversionService(), resolver); + } + + @Test + public void supports() throws NoSuchMethodException { + Object controller = new TestController(); + testSupports(controller, "handleToString", true); + testSupports(controller, "doWork", false); + + controller = new TestRestController(); + testSupports(controller, "handleToString", true); + testSupports(controller, "handleToResponseEntity", false); + testSupports(controller, "handleToMonoResponseEntity", false); + } + + private void testSupports(Object controller, String method, boolean result) throws NoSuchMethodException { + HandlerMethod hm = handlerMethod(controller, method); + HandlerResult handlerResult = new HandlerResult(hm, null, hm.getReturnType(), new ExtendedModelMap()); + assertEquals(result, this.resultHandler.supports(handlerResult)); + } + + @Test + public void defaultOrder() throws Exception { + assertEquals(100, this.resultHandler.getOrder()); + } + + + private HandlerMethod handlerMethod(Object controller, String method) throws NoSuchMethodException { + return new HandlerMethod(controller, controller.getClass().getMethod(method)); + } + + + @RestController @SuppressWarnings("unused") + private static class TestRestController { + + public String handleToString() { + return null; + } + + public ResponseEntity handleToResponseEntity() { + return null; + } + + public Mono> handleToMonoResponseEntity() { + return null; + } + } + + @Controller @SuppressWarnings("unused") + private static class TestController { + + @ResponseBody + public String handleToString() { + return null; + } + + public String doWork() { + return null; + } + + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandlerTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandlerTests.java new file mode 100644 index 0000000000..22a878cbea --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandlerTests.java @@ -0,0 +1,229 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import java.net.URI; +import java.nio.charset.Charset; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Mono; +import reactor.core.test.TestSubscriber; +import rx.Single; + +import org.springframework.core.MethodParameter; +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.ByteBufferEncoder; +import org.springframework.core.codec.StringEncoder; +import org.springframework.core.convert.support.MonoToCompletableFutureConverter; +import org.springframework.core.convert.support.ReactorToRxJava1Converter; +import org.springframework.core.io.buffer.support.DataBufferTestUtils; +import org.springframework.format.support.DefaultFormattingConversionService; +import org.springframework.format.support.FormattingConversionService; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.codec.json.JacksonJsonEncoder; +import org.springframework.http.codec.xml.Jaxb2Encoder; +import org.springframework.http.converter.reactive.CodecHttpMessageConverter; +import org.springframework.http.converter.reactive.HttpMessageConverter; +import org.springframework.http.converter.reactive.ResourceHttpMessageConverter; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.util.ObjectUtils; +import org.springframework.web.reactive.HandlerResult; +import org.springframework.web.reactive.accept.RequestedContentTypeResolver; +import org.springframework.web.reactive.accept.RequestedContentTypeResolverBuilder; +import org.springframework.web.reactive.result.ResolvableMethod; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.springframework.core.ResolvableType.forClassWithGenerics; + +/** + * Unit tests for {@link ResponseEntityResultHandler}. When adding a test also + * consider whether the logic under test is in a parent class, then see: + *
    + *
  • {@code MessageConverterResultHandlerTests}, + *
  • {@code ContentNegotiatingResultHandlerSupportTests} + *
+ * @author Rossen Stoyanchev + */ +public class ResponseEntityResultHandlerTests { + + private ResponseEntityResultHandler resultHandler; + + private MockServerHttpResponse response = new MockServerHttpResponse(); + + private ServerWebExchange exchange; + + + @Before + public void setUp() throws Exception { + this.resultHandler = createHandler(); + ServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, new URI("/path")); + this.exchange = new DefaultServerWebExchange(request, this.response, new MockWebSessionManager()); + } + + private ResponseEntityResultHandler createHandler(HttpMessageConverter... converters) { + List> converterList; + if (ObjectUtils.isEmpty(converters)) { + converterList = new ArrayList<>(); + converterList.add(new CodecHttpMessageConverter<>(new ByteBufferEncoder())); + converterList.add(new CodecHttpMessageConverter<>(new StringEncoder())); + converterList.add(new ResourceHttpMessageConverter()); + converterList.add(new CodecHttpMessageConverter<>(new Jaxb2Encoder())); + converterList.add(new CodecHttpMessageConverter<>(new JacksonJsonEncoder())); + } + else { + converterList = Arrays.asList(converters); + } + FormattingConversionService service = new DefaultFormattingConversionService(); + service.addConverter(new MonoToCompletableFutureConverter()); + service.addConverter(new ReactorToRxJava1Converter()); + + + RequestedContentTypeResolver resolver = new RequestedContentTypeResolverBuilder().build(); + + return new ResponseEntityResultHandler(converterList, service, resolver); + } + + + @Test @SuppressWarnings("ConstantConditions") + public void supports() throws NoSuchMethodException { + + Object value = null; + ResolvableType type = responseEntity(String.class); + assertTrue(this.resultHandler.supports(handlerResult(value, type))); + + type = forClassWithGenerics(Mono.class, responseEntity(String.class)); + assertTrue(this.resultHandler.supports(handlerResult(value, type))); + + type = forClassWithGenerics(Single.class, responseEntity(String.class)); + assertTrue(this.resultHandler.supports(handlerResult(value, type))); + + type = forClassWithGenerics(CompletableFuture.class, responseEntity(String.class)); + assertTrue(this.resultHandler.supports(handlerResult(value, type))); + + type = ResolvableType.forClass(String.class); + assertFalse(this.resultHandler.supports(handlerResult(value, type))); + } + + @Test + public void defaultOrder() throws Exception { + assertEquals(0, this.resultHandler.getOrder()); + } + + @Test + public void statusCode() throws Exception { + ResponseEntity value = ResponseEntity.noContent().build(); + ResolvableType type = responseEntity(Void.class); + HandlerResult result = handlerResult(value, type); + this.resultHandler.handleResult(exchange, result).block(Duration.ofSeconds(5)); + + assertEquals(HttpStatus.NO_CONTENT, this.response.getStatusCode()); + assertEquals(0, this.response.getHeaders().size()); + assertNull(this.response.getBody()); + } + + @Test + public void headers() throws Exception { + URI location = new URI("/path"); + ResolvableType type = responseEntity(Void.class); + ResponseEntity value = ResponseEntity.created(location).build(); + HandlerResult result = handlerResult(value, type); + this.resultHandler.handleResult(this.exchange, result).block(Duration.ofSeconds(5)); + + assertEquals(HttpStatus.CREATED, this.response.getStatusCode()); + assertEquals(1, this.response.getHeaders().size()); + assertEquals(location, this.response.getHeaders().getLocation()); + assertNull(this.response.getBody()); + } + + @Test + public void handleReturnTypes() throws Exception { + Object returnValue = ResponseEntity.ok("abc"); + ResolvableType returnType = responseEntity(String.class); + testHandle(returnValue, returnType); + + returnValue = Mono.just(ResponseEntity.ok("abc")); + returnType = forClassWithGenerics(Mono.class, responseEntity(String.class)); + testHandle(returnValue, returnType); + + returnValue = Mono.just(ResponseEntity.ok("abc")); + returnType = forClassWithGenerics(Single.class, responseEntity(String.class)); + testHandle(returnValue, returnType); + + returnValue = Mono.just(ResponseEntity.ok("abc")); + returnType = forClassWithGenerics(CompletableFuture.class, responseEntity(String.class)); + testHandle(returnValue, returnType); + } + + + private void testHandle(Object returnValue, ResolvableType type) { + HandlerResult result = handlerResult(returnValue, type); + this.resultHandler.handleResult(this.exchange, result).block(Duration.ofSeconds(5)); + + assertEquals(HttpStatus.OK, this.response.getStatusCode()); + assertEquals("text/plain;charset=UTF-8", this.response.getHeaders().getFirst("Content-Type")); + assertResponseBody("abc"); + } + + + private ResolvableType responseEntity(Class bodyType) { + return forClassWithGenerics(ResponseEntity.class, ResolvableType.forClass(bodyType)); + } + + private HandlerResult handlerResult(Object returnValue, ResolvableType type) { + MethodParameter param = ResolvableMethod.onClass(TestController.class).returning(type).resolveReturnType(); + return new HandlerResult(new TestController(), returnValue, param); + } + + private void assertResponseBody(String responseBody) { + TestSubscriber.subscribe(this.response.getBody()) + .assertValuesWith(buf -> assertEquals(responseBody, + DataBufferTestUtils.dumpString(buf, Charset.forName("UTF-8")))); + } + + + @SuppressWarnings("unused") + private static class TestController { + + ResponseEntity responseEntityString() { return null; } + + ResponseEntity responseEntityVoid() { return null; } + + Mono> mono() { return null; } + + Single> single() { return null; } + + CompletableFuture> completableFuture() { return null; } + + String string() { return null; } + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/SessionAttributeMethodArgumentResolverTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/SessionAttributeMethodArgumentResolverTests.java new file mode 100644 index 0000000000..fe3e3f1b7a --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/SessionAttributeMethodArgumentResolverTests.java @@ -0,0 +1,171 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import java.lang.reflect.Method; +import java.net.URI; +import java.util.Optional; + +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Mono; +import reactor.core.test.TestSubscriber; + +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.core.DefaultParameterNameDiscoverer; +import org.springframework.core.GenericTypeResolver; +import org.springframework.core.MethodParameter; +import org.springframework.core.annotation.SynthesizingMethodParameter; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.http.HttpMethod; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.util.ReflectionUtils; +import org.springframework.web.bind.annotation.SessionAttribute; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.ServerWebInputException; +import org.springframework.web.server.WebSession; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; +import org.springframework.web.server.session.WebSessionManager; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link SessionAttributeMethodArgumentResolver}. + * @author Rossen Stoyanchev + */ +public class SessionAttributeMethodArgumentResolverTests { + + private SessionAttributeMethodArgumentResolver resolver; + + private ServerWebExchange exchange; + + private WebSession session; + + private Method handleMethod; + + + @Before + @SuppressWarnings("ConfusingArgumentToVarargsMethod") + public void setUp() throws Exception { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.refresh(); + ConversionService cs = new DefaultConversionService(); + this.resolver = new SessionAttributeMethodArgumentResolver(cs, context.getBeanFactory()); + + this.session = mock(WebSession.class); + when(this.session.getAttribute(any())).thenReturn(Optional.empty()); + + ServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, new URI("/")); + WebSessionManager sessionManager = new MockWebSessionManager(this.session); + this.exchange = new DefaultServerWebExchange(request, new MockServerHttpResponse(), sessionManager); + + this.handleMethod = ReflectionUtils.findMethod(getClass(), "handleWithSessionAttribute", (Class[]) null); + } + + @Test + public void supportsParameter() throws Exception { + assertTrue(this.resolver.supportsParameter(new MethodParameter(this.handleMethod, 0))); + assertFalse(this.resolver.supportsParameter(new MethodParameter(this.handleMethod, 4))); + } + + @Test + public void resolve() throws Exception { + MethodParameter param = initMethodParameter(0); + Mono mono = this.resolver.resolveArgument(param, null, this.exchange); + TestSubscriber + .subscribe(mono) + .assertError(ServerWebInputException.class); + + Foo foo = new Foo(); + when(this.session.getAttribute("foo")).thenReturn(Optional.of(foo)); + mono = this.resolver.resolveArgument(param, null, this.exchange); + assertSame(foo, mono.block()); + } + + @Test + public void resolveWithName() throws Exception { + MethodParameter param = initMethodParameter(1); + Foo foo = new Foo(); + when(this.session.getAttribute("specialFoo")).thenReturn(Optional.of(foo)); + Mono mono = this.resolver.resolveArgument(param, null, this.exchange); + assertSame(foo, mono.block()); + } + + @Test + public void resolveNotRequired() throws Exception { + MethodParameter param = initMethodParameter(2); + Mono mono = this.resolver.resolveArgument(param, null, this.exchange); + assertNull(mono.block()); + + Foo foo = new Foo(); + when(this.session.getAttribute("foo")).thenReturn(Optional.of(foo)); + mono = this.resolver.resolveArgument(param, null, this.exchange); + assertSame(foo, mono.block()); + } + + @Test + public void resolveOptional() throws Exception { + MethodParameter param = initMethodParameter(3); + Mono mono = this.resolver.resolveArgument(param, null, this.exchange); + assertNotNull(mono.block()); + assertEquals(Optional.class, mono.block().getClass()); + assertFalse(((Optional) mono.block()).isPresent()); + + Foo foo = new Foo(); + when(this.session.getAttribute("foo")).thenReturn(Optional.of(foo)); + mono = this.resolver.resolveArgument(param, null, this.exchange); + + assertNotNull(mono.block()); + assertEquals(Optional.class, mono.block().getClass()); + Optional optional = (Optional) mono.block(); + assertTrue(optional.isPresent()); + assertSame(foo, optional.get()); + } + + + private MethodParameter initMethodParameter(int parameterIndex) { + MethodParameter param = new SynthesizingMethodParameter(this.handleMethod, parameterIndex); + param.initParameterNameDiscovery(new DefaultParameterNameDiscoverer()); + GenericTypeResolver.resolveParameterType(param, this.resolver.getClass()); + return param; + } + + + @SuppressWarnings({"unused", "OptionalUsedAsFieldOrParameterType"}) + private void handleWithSessionAttribute( + @SessionAttribute Foo foo, + @SessionAttribute("specialFoo") Foo namedFoo, + @SessionAttribute(name="foo", required = false) Foo notRequiredFoo, + @SessionAttribute(name="foo") Optional optionalFoo, + String notSupported) { + } + + private static class Foo { + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/SseIntegrationTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/SseIntegrationTests.java new file mode 100644 index 0000000000..9de15cab87 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/SseIntegrationTests.java @@ -0,0 +1,232 @@ +/* + * Copyright 2002-2016 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.reactive.result.method.annotation; + +import static org.springframework.web.client.reactive.ClientWebRequestBuilders.*; +import static org.springframework.web.client.reactive.ResponseExtractors.*; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.test.TestSubscriber; + +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.codec.ByteBufferDecoder; +import org.springframework.core.codec.ByteBufferEncoder; +import org.springframework.core.codec.Encoder; +import org.springframework.core.codec.StringDecoder; +import org.springframework.core.codec.StringEncoder; +import org.springframework.http.MediaType; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.http.codec.SseEventEncoder; +import org.springframework.http.codec.json.JacksonJsonDecoder; +import org.springframework.http.codec.json.JacksonJsonEncoder; +import org.springframework.http.converter.reactive.CodecHttpMessageConverter; +import org.springframework.http.converter.reactive.HttpMessageConverter; +import org.springframework.http.server.reactive.AbstractHttpHandlerIntegrationTests; +import org.springframework.http.server.reactive.HttpHandler; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.reactive.WebClient; +import org.springframework.web.reactive.DispatcherHandler; +import org.springframework.web.reactive.config.WebReactiveConfiguration; +import org.springframework.web.reactive.sse.SseEvent; +import org.springframework.web.server.adapter.WebHttpHandlerBuilder; + +/** + * @author Sebastien Deleuze + */ +public class SseIntegrationTests extends AbstractHttpHandlerIntegrationTests { + + private AnnotationConfigApplicationContext wac; + + private WebClient webClient; + + @Before + public void setup() throws Exception { + super.setup(); + this.webClient = new WebClient(new ReactorClientHttpConnector()); + List> converters = new ArrayList<>(); + converters.add(new CodecHttpMessageConverter<>(new ByteBufferEncoder(), new ByteBufferDecoder())); + converters.add(new CodecHttpMessageConverter<>(new StringEncoder(), new StringDecoder(false))); + converters.add(new CodecHttpMessageConverter<>(new JacksonJsonEncoder(), new JacksonJsonDecoder())); + this.webClient.setMessageConverters(converters); + } + + @Override + protected HttpHandler createHttpHandler() { + this.wac = new AnnotationConfigApplicationContext(); + this.wac.register(TestConfiguration.class); + this.wac.refresh(); + + DispatcherHandler webHandler = new DispatcherHandler(); + webHandler.setApplicationContext(this.wac); + + return WebHttpHandlerBuilder.webHandler(webHandler).build(); + } + + @Test + public void sseAsString() throws Exception { + Flux result = this.webClient + .perform(get("http://localhost:" + port + "/sse/string") + .accept(new MediaType("text", "event-stream"))) + .extract(bodyStream(String.class)) + .filter(s -> !s.equals("\n")) + .map(s -> (s.replace("\n", ""))) + .take(2); + + TestSubscriber + .subscribe(result) + .await() + .assertValues("data:foo 0", "data:foo 1"); + } + + @Test + public void sseAsPojo() throws Exception { + Mono result = this.webClient + .perform(get("http://localhost:" + port + "/sse/person") + .accept(new MediaType("text", "event-stream"))) + .extract(bodyStream(String.class)) + .filter(s -> !s.equals("\n")) + .map(s -> (s.replace("\n", ""))) + .takeUntil(s -> { + return s.endsWith("foo 1\"}"); + }) + .reduce((s1, s2) -> s1 + s2); + + TestSubscriber + .subscribe(result) + .await() + .assertValues("data:{\"name\":\"foo 0\"}data:{\"name\":\"foo 1\"}"); + } + + @Test + public void sseAsEvent() throws Exception { + Flux result = this.webClient + .perform(get("http://localhost:" + port + "/sse/event") + .accept(new MediaType("text", "event-stream"))) + .extract(bodyStream(String.class)) + .filter(s -> !s.equals("\n")) + .map(s -> (s.replace("\n", ""))) + .take(2); + + TestSubscriber + .subscribe(result) + .await() + .assertValues( + "id:0:bardata:foo", + "id:1:bardata:foo" + ); + } + + @RestController + @SuppressWarnings("unused") + static class SseController { + + @RequestMapping("/sse/string") + Flux string() { + return Flux.interval(Duration.ofMillis(100)).map(l -> "foo " + l).take(2); + } + + @RequestMapping("/sse/person") + Flux person() { + return Flux.interval(Duration.ofMillis(100)).map(l -> new Person("foo " + l)).take(2); + } + + @RequestMapping("/sse/event") + Flux sse() { + return Flux.interval(Duration.ofMillis(100)).map(l -> { + SseEvent event = new SseEvent(); + event.setId(Long.toString(l)); + event.setData("foo"); + event.setComment("bar"); + return event; + }).take(2); + } + + } + + @Configuration + @SuppressWarnings("unused") + static class TestConfiguration extends WebReactiveConfiguration { + + @Bean + public SseController sseController() { + return new SseController(); + } + + @Override + protected void extendMessageConverters(List> converters) { + Encoder sseEncoder = new SseEventEncoder(Arrays.asList(new JacksonJsonEncoder())); + converters.add(new CodecHttpMessageConverter<>(sseEncoder)); + } + } + + private static class Person { + + private String name; + + @SuppressWarnings("unused") + public Person() { + } + + public Person(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Person person = (Person) o; + return !(this.name != null ? !this.name.equals(person.name) : person.name != null); + } + + @Override + public int hashCode() { + return this.name != null ? this.name.hashCode() : 0; + } + + @Override + public String toString() { + return "Person{" + + "name='" + name + '\'' + + '}'; + } + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/view/HttpMessageConverterViewTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/view/HttpMessageConverterViewTests.java new file mode 100644 index 0000000000..7af0bb3849 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/view/HttpMessageConverterViewTests.java @@ -0,0 +1,184 @@ +/* + * Copyright 2002-2016 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.reactive.result.view; + + +import java.net.URI; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; +import reactor.core.test.TestSubscriber; + +import org.springframework.core.MethodParameter; +import org.springframework.core.codec.StringEncoder; +import org.springframework.core.io.buffer.support.DataBufferTestUtils; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.codec.json.JacksonJsonEncoder; +import org.springframework.http.codec.xml.Jaxb2Encoder; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.ui.ExtendedModelMap; +import org.springframework.ui.ModelMap; +import org.springframework.util.MimeType; +import org.springframework.web.reactive.HandlerResult; +import org.springframework.web.reactive.result.ResolvableMethod; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.DefaultWebSessionManager; +import org.springframework.web.server.session.WebSessionManager; + +import static junit.framework.TestCase.assertTrue; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.fail; + + +/** + * Unit tests for {@link HttpMessageConverterView}. + * @author Rossen Stoyanchev + */ +public class HttpMessageConverterViewTests { + + private HttpMessageConverterView view = new HttpMessageConverterView(new JacksonJsonEncoder()); + + private HandlerResult result; + + private ModelMap model = new ExtendedModelMap(); + + + @Before + public void setup() throws Exception { + MethodParameter param = ResolvableMethod.onClass(this.getClass()).name("handle").resolveReturnType(); + this.result = new HandlerResult(this, null, param, this.model); + } + + + @Test + public void supportedMediaTypes() throws Exception { + List mimeTypes = Arrays.asList( + new MimeType("application", "json", StandardCharsets.UTF_8), + new MimeType("application", "*+json", StandardCharsets.UTF_8)); + + assertEquals(mimeTypes, this.view.getSupportedMediaTypes()); + } + + @Test + public void extractObject() throws Exception { + this.view.setModelKeys(Collections.singleton("foo2")); + this.model.addAttribute("foo1", "bar1"); + this.model.addAttribute("foo2", "bar2"); + this.model.addAttribute("foo3", "bar3"); + + assertEquals("bar2", this.view.extractObjectToRender(this.result)); + } + + @Test + public void extractObjectNoMatch() throws Exception { + this.view.setModelKeys(Collections.singleton("foo2")); + this.model.addAttribute("foo1", "bar1"); + + assertNull(this.view.extractObjectToRender(this.result)); + } + + @Test + public void extractObjectMultipleMatches() throws Exception { + this.view.setModelKeys(new HashSet<>(Arrays.asList("foo1", "foo2"))); + this.model.addAttribute("foo1", "bar1"); + this.model.addAttribute("foo2", "bar2"); + this.model.addAttribute("foo3", "bar3"); + + Object value = this.view.extractObjectToRender(this.result); + assertNotNull(value); + assertEquals(HashMap.class, value.getClass()); + + Map map = (Map) value; + assertEquals(2, map.size()); + assertEquals("bar1", map.get("foo1")); + assertEquals("bar2", map.get("foo2")); + } + + @Test + public void extractObjectMultipleMatchesNotSupported() throws Exception { + HttpMessageConverterView view = new HttpMessageConverterView(new StringEncoder()); + view.setModelKeys(new HashSet<>(Arrays.asList("foo1", "foo2"))); + this.model.addAttribute("foo1", "bar1"); + this.model.addAttribute("foo2", "bar2"); + + try { + view.extractObjectToRender(this.result); + fail(); + } + catch (IllegalStateException ex) { + String message = ex.getMessage(); + assertTrue(message, message.contains("Map rendering is not supported")); + } + } + + @Test + public void extractObjectNotSupported() throws Exception { + HttpMessageConverterView view = new HttpMessageConverterView(new Jaxb2Encoder()); + view.setModelKeys(new HashSet<>(Collections.singletonList("foo1"))); + this.model.addAttribute("foo1", "bar1"); + + try { + view.extractObjectToRender(this.result); + fail(); + } + catch (IllegalStateException ex) { + String message = ex.getMessage(); + assertTrue(message, message.contains("[foo1] is not supported")); + } + } + + @Test + public void render() throws Exception { + Map pojoData = new LinkedHashMap<>(); + pojoData.put("foo", "f"); + pojoData.put("bar", "b"); + this.model.addAttribute("pojoData", pojoData); + this.view.setModelKeys(Collections.singleton("pojoData")); + + MockServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, new URI("/path")); + MockServerHttpResponse response = new MockServerHttpResponse(); + WebSessionManager manager = new DefaultWebSessionManager(); + ServerWebExchange exchange = new DefaultServerWebExchange(request, response, manager); + + this.view.render(result, MediaType.APPLICATION_JSON, exchange); + + TestSubscriber + .subscribe(response.getBody()) + .assertValuesWith(buf -> assertEquals("{\"foo\":\"f\",\"bar\":\"b\"}", + DataBufferTestUtils.dumpString(buf, Charset.forName("UTF-8")))); + } + + + @SuppressWarnings("unused") + private String handle() { + return null; + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/view/UrlBasedViewResolverTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/view/UrlBasedViewResolverTests.java new file mode 100644 index 0000000000..11d61c1dcf --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/view/UrlBasedViewResolverTests.java @@ -0,0 +1,69 @@ +/* + * Copyright 2002-2016 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.reactive.result.view; + +import java.util.Locale; +import java.util.Map; + +import org.junit.Test; +import reactor.core.publisher.Mono; + +import org.springframework.context.support.StaticApplicationContext; +import org.springframework.web.server.ServerWebExchange; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +/** + * Unit tests for {@link UrlBasedViewResolver}. + * + * @author Rossen Stoyanchev + */ +public class UrlBasedViewResolverTests { + + + @Test + public void viewNames() throws Exception { + StaticApplicationContext context = new StaticApplicationContext(); + context.refresh(); + + UrlBasedViewResolver resolver = new UrlBasedViewResolver(); + resolver.setViewClass(TestView.class); + resolver.setViewNames("my*"); + resolver.setApplicationContext(context); + + Mono mono = resolver.resolveViewName("my-view", Locale.US); + assertNotNull(mono.block()); + + mono = resolver.resolveViewName("not-my-view", Locale.US); + assertNull(mono.block()); + } + + + private static class TestView extends AbstractUrlBasedView { + + @Override + public boolean checkResourceExists(Locale locale) throws Exception { + return true; + } + + @Override + protected Mono renderInternal(Map attributes, ServerWebExchange exchange) { + return Mono.empty(); + } + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandlerTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandlerTests.java new file mode 100644 index 0000000000..61f9720a28 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandlerTests.java @@ -0,0 +1,409 @@ +/* + * Copyright 2002-2016 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.reactive.result.view; + +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.time.Duration; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.test.TestSubscriber; +import rx.Single; + +import org.springframework.core.MethodParameter; +import org.springframework.core.Ordered; +import org.springframework.core.ResolvableType; +import org.springframework.core.convert.support.MonoToCompletableFutureConverter; +import org.springframework.core.convert.support.ReactorToRxJava1Converter; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.core.io.buffer.support.DataBufferTestUtils; +import org.springframework.format.support.DefaultFormattingConversionService; +import org.springframework.format.support.FormattingConversionService; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.ui.ExtendedModelMap; +import org.springframework.ui.Model; +import org.springframework.ui.ModelMap; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.reactive.HandlerResult; +import org.springframework.web.reactive.result.ResolvableMethod; +import org.springframework.web.server.NotAcceptableStatusException; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.DefaultWebSessionManager; +import org.springframework.web.server.session.WebSessionManager; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.springframework.http.MediaType.APPLICATION_JSON; + +/** + * Unit tests for {@link ViewResolutionResultHandler}. + * @author Rossen Stoyanchev + */ +public class ViewResolutionResultHandlerTests { + + private MockServerHttpRequest request; + + private MockServerHttpResponse response = new MockServerHttpResponse(); + + private ServerWebExchange exchange; + + private ModelMap model = new ExtendedModelMap(); + + + @Before + public void setUp() throws Exception { + this.request = new MockServerHttpRequest(HttpMethod.GET, new URI("/path")); + WebSessionManager manager = new DefaultWebSessionManager(); + this.exchange = new DefaultServerWebExchange(this.request, this.response, manager); + } + + + @Test + public void supports() throws Exception { + + testSupports(ResolvableType.forClass(String.class), true); + testSupports(ResolvableType.forClass(View.class), true); + testSupports(ResolvableType.forClassWithGenerics(Mono.class, String.class), true); + testSupports(ResolvableType.forClassWithGenerics(Mono.class, View.class), true); + testSupports(ResolvableType.forClassWithGenerics(Single.class, String.class), true); + testSupports(ResolvableType.forClassWithGenerics(Single.class, View.class), true); + testSupports(ResolvableType.forClass(Model.class), true); + testSupports(ResolvableType.forClass(Map.class), true); + testSupports(ResolvableType.forClass(TestBean.class), true); + testSupports(ResolvableType.forClass(Integer.class), false); + + testSupports(ResolvableMethod.onClass(TestController.class).annotated(ModelAttribute.class), true); + } + + @Test + public void viewResolverOrder() throws Exception { + TestViewResolver resolver1 = new TestViewResolver("account"); + TestViewResolver resolver2 = new TestViewResolver("profile"); + resolver1.setOrder(2); + resolver2.setOrder(1); + List resolvers = createResultHandler(resolver1, resolver2).getViewResolvers(); + + assertEquals(Arrays.asList(resolver2, resolver1), resolvers); + } + + @Test + public void handleReturnValueTypes() throws Exception { + Object returnValue; + ResolvableType returnType; + ViewResolver resolver = new TestViewResolver("account"); + + returnType = ResolvableType.forClass(View.class); + returnValue = new TestView("account"); + testHandle("/path", returnType, returnValue, "account: {id=123}"); + + returnType = ResolvableType.forClassWithGenerics(Mono.class, View.class); + returnValue = Mono.just(new TestView("account")); + testHandle("/path", returnType, returnValue, "account: {id=123}"); + + returnType = ResolvableType.forClass(String.class); + returnValue = "account"; + testHandle("/path", returnType, returnValue, "account: {id=123}", resolver); + + returnType = ResolvableType.forClassWithGenerics(Mono.class, String.class); + returnValue = Mono.just("account"); + testHandle("/path", returnType, returnValue, "account: {id=123}", resolver); + + returnType = ResolvableType.forClass(Model.class); + returnValue = new ExtendedModelMap().addAttribute("name", "Joe"); + testHandle("/account", returnType, returnValue, "account: {id=123, name=Joe}", resolver); + + returnType = ResolvableType.forClass(Map.class); + returnValue = Collections.singletonMap("name", "Joe"); + testHandle("/account", returnType, returnValue, "account: {id=123, name=Joe}", resolver); + + returnType = ResolvableType.forClass(TestBean.class); + returnValue = new TestBean("Joe"); + String responseBody = "account: {id=123, testBean=TestBean[name=Joe]}"; + testHandle("/account", returnType, returnValue, responseBody, resolver); + + testHandle("/account", ResolvableMethod.onClass(TestController.class).annotated(ModelAttribute.class), + 99L, "account: {id=123, num=99}", resolver); + } + + @Test + public void handleWithMultipleResolvers() throws Exception { + Object returnValue = "profile"; + ResolvableType returnType = ResolvableType.forClass(String.class); + ViewResolver[] resolvers = {new TestViewResolver("account"), new TestViewResolver("profile")}; + + testHandle("/account", returnType, returnValue, "profile: {id=123}", resolvers); + } + + @Test + public void defaultViewName() throws Exception { + testDefaultViewName(null, ResolvableType.forClass(String.class)); + testDefaultViewName(Mono.empty(), ResolvableType.forClassWithGenerics(Mono.class, String.class)); + } + + private void testDefaultViewName(Object returnValue, ResolvableType type) + throws URISyntaxException { + + ModelMap model = new ExtendedModelMap().addAttribute("id", "123"); + HandlerResult result = new HandlerResult(new Object(), returnValue, returnType(type), model); + ViewResolutionResultHandler handler = createResultHandler(new TestViewResolver("account")); + + this.request.setUri(new URI("/account")); + handler.handleResult(this.exchange, result).block(Duration.ofSeconds(5)); + assertResponseBody("account: {id=123}"); + + this.request.setUri(new URI("/account/")); + handler.handleResult(this.exchange, result).block(Duration.ofSeconds(5)); + assertResponseBody("account: {id=123}"); + + this.request.setUri(new URI("/account.123")); + handler.handleResult(this.exchange, result).block(Duration.ofSeconds(5)); + assertResponseBody("account: {id=123}"); + } + + @Test + public void unresolvedViewName() throws Exception { + String returnValue = "account"; + ResolvableType type = ResolvableType.forClass(String.class); + HandlerResult handlerResult = new HandlerResult(new Object(), returnValue, returnType(type), this.model); + + this.request.setUri(new URI("/path")); + Mono mono = createResultHandler().handleResult(this.exchange, handlerResult); + + TestSubscriber.subscribe(mono).assertErrorMessage("Could not resolve view with name 'account'."); + } + + @Test + public void contentNegotiation() throws Exception { + TestBean value = new TestBean("Joe"); + ResolvableType type = ResolvableType.forClass(TestBean.class); + HandlerResult handlerResult = new HandlerResult(new Object(), value, returnType(type), this.model); + + this.request.getHeaders().setAccept(Collections.singletonList(APPLICATION_JSON)); + this.request.setUri(new URI("/account")); + + TestView defaultView = new TestView("jsonView", APPLICATION_JSON); + + createResultHandler(Collections.singletonList(defaultView), new TestViewResolver("account")) + .handleResult(this.exchange, handlerResult) + .block(Duration.ofSeconds(5)); + + assertEquals(APPLICATION_JSON, this.response.getHeaders().getContentType()); + assertResponseBody("jsonView: {testBean=TestBean[name=Joe]}"); + } + + @Test + public void contentNegotiationWith406() throws Exception { + TestBean value = new TestBean("Joe"); + ResolvableType type = ResolvableType.forClass(TestBean.class); + HandlerResult handlerResult = new HandlerResult(new Object(), value, returnType(type), this.model); + + this.request.getHeaders().setAccept(Collections.singletonList(APPLICATION_JSON)); + this.request.setUri(new URI("/account")); + + ViewResolutionResultHandler resultHandler = createResultHandler(new TestViewResolver("account")); + Mono mono = resultHandler.handleResult(this.exchange, handlerResult); + TestSubscriber.subscribe(mono).assertError(NotAcceptableStatusException.class); + } + + + private MethodParameter returnType(ResolvableType type) { + return ResolvableMethod.onClass(TestController.class).returning(type).resolveReturnType(); + } + + private ViewResolutionResultHandler createResultHandler(ViewResolver... resolvers) { + return createResultHandler(Collections.emptyList(), resolvers); + } + + private ViewResolutionResultHandler createResultHandler(List defaultViews, ViewResolver... resolvers) { + FormattingConversionService service = new DefaultFormattingConversionService(); + service.addConverter(new MonoToCompletableFutureConverter()); + service.addConverter(new ReactorToRxJava1Converter()); + List resolverList = Arrays.asList(resolvers); + + ViewResolutionResultHandler handler = new ViewResolutionResultHandler(resolverList, service); + handler.setDefaultViews(defaultViews); + return handler; + } + + private void testSupports(ResolvableType type, boolean result) { + testSupports(ResolvableMethod.onClass(TestController.class).returning(type), result); + } + + private void testSupports(ResolvableMethod resolvableMethod, boolean result) { + ViewResolutionResultHandler resultHandler = createResultHandler(mock(ViewResolver.class)); + MethodParameter returnType = resolvableMethod.resolveReturnType(); + HandlerResult handlerResult = new HandlerResult(new Object(), null, returnType, this.model); + assertEquals(result, resultHandler.supports(handlerResult)); + } + + private void testHandle(String path, ResolvableType returnType, Object returnValue, + String responseBody, ViewResolver... resolvers) throws URISyntaxException { + + testHandle(path, ResolvableMethod.onClass(TestController.class).returning(returnType), + returnValue, responseBody, resolvers); + } + + private void testHandle(String path, ResolvableMethod resolvableMethod, Object returnValue, + String responseBody, ViewResolver... resolvers) throws URISyntaxException { + + ModelMap model = new ExtendedModelMap().addAttribute("id", "123"); + MethodParameter returnType = resolvableMethod.resolveReturnType(); + HandlerResult result = new HandlerResult(new Object(), returnValue, returnType, model); + this.request.setUri(new URI(path)); + createResultHandler(resolvers).handleResult(this.exchange, result).block(Duration.ofSeconds(5)); + assertResponseBody(responseBody); + } + + private void assertResponseBody(String responseBody) { + TestSubscriber.subscribe(this.response.getBody()) + .assertValuesWith(buf -> assertEquals(responseBody, + DataBufferTestUtils.dumpString(buf, Charset.forName("UTF-8")))); + } + + + private static class TestViewResolver implements ViewResolver, Ordered { + + private final Map views = new HashMap<>(); + + private int order = Ordered.LOWEST_PRECEDENCE; + + + TestViewResolver(String... viewNames) { + Arrays.stream(viewNames).forEach(name -> this.views.put(name, new TestView(name))); + } + + void setOrder(int order) { + this.order = order; + } + + @Override + public int getOrder() { + return this.order; + } + + @Override + public Mono resolveViewName(String viewName, Locale locale) { + View view = this.views.get(viewName); + return Mono.justOrEmpty(view); + } + + } + + private static final class TestView implements View { + + private final String name; + + private final List mediaTypes; + + + TestView(String name) { + this.name = name; + this.mediaTypes = Collections.singletonList(MediaType.TEXT_HTML); + } + + TestView(String name, MediaType... mediaTypes) { + this.name = name; + this.mediaTypes = Arrays.asList(mediaTypes); + } + + public String getName() { + return this.name; + } + + @Override + public List getSupportedMediaTypes() { + return this.mediaTypes; + } + + @Override + public Mono render(HandlerResult result, MediaType mediaType, ServerWebExchange exchange) { + String value = this.name + ": " + result.getModel().toString(); + assertNotNull(value); + ServerHttpResponse response = exchange.getResponse(); + if (mediaType != null) { + response.getHeaders().setContentType(mediaType); + } + ByteBuffer byteBuffer = ByteBuffer.wrap(value.getBytes(Charset.forName("UTF-8"))); + DataBuffer dataBuffer = new DefaultDataBufferFactory().wrap(byteBuffer); + return response.writeWith(Flux.just(dataBuffer)); + } + } + + private static class TestBean { + + private final String name; + + TestBean(String name) { + this.name = name; + } + + public String getName() { + return this.name; + } + + @Override + public String toString() { + return "TestBean[name=" + this.name + "]"; + } + } + + @SuppressWarnings("unused") + private static class TestController { + + String string() { return null; } + + View view() { return null; } + + Mono monoString() { return null; } + + Mono monoView() { return null; } + + Single singleString() { return null; } + + Single singleView() { return null; } + + Model model() { return null; } + + Map map() { return null; } + + TestBean testBean() { return null; } + + Integer integer() { return null; } + + @ModelAttribute("num") + Long longAttribute() { return null; } + } + +} \ No newline at end of file diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/view/freemarker/FreeMarkerViewTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/view/freemarker/FreeMarkerViewTests.java new file mode 100644 index 0000000000..147c269acc --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/view/freemarker/FreeMarkerViewTests.java @@ -0,0 +1,152 @@ +/* + * Copyright 2002-2016 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.reactive.result.view.freemarker; + +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.util.Locale; + +import freemarker.template.Configuration; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import reactor.core.test.TestSubscriber; + +import org.springframework.context.ApplicationContextException; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.core.MethodParameter; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.HttpMethod; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.ui.ExtendedModelMap; +import org.springframework.ui.ModelMap; +import org.springframework.web.reactive.HandlerResult; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.DefaultWebSessionManager; +import org.springframework.web.server.session.WebSessionManager; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * @author Rossen Stoyanchev + */ +public class FreeMarkerViewTests { + + public static final String TEMPLATE_PATH = "classpath*:org/springframework/web/reactive/view/freemarker/"; + + private static final Charset UTF_8 = Charset.forName("UTF-8"); + + + private ServerWebExchange exchange; + + private MockServerHttpResponse response; + + private GenericApplicationContext context; + + private Configuration freeMarkerConfig; + + @Rule + public final ExpectedException exception = ExpectedException.none(); + + + @Before + public void setUp() throws Exception { + this.context = new GenericApplicationContext(); + this.context.refresh(); + + FreeMarkerConfigurer configurer = new FreeMarkerConfigurer(); + configurer.setPreferFileSystemAccess(false); + configurer.setTemplateLoaderPath(TEMPLATE_PATH); + configurer.setResourceLoader(this.context); + this.freeMarkerConfig = configurer.createConfiguration(); + + FreeMarkerView fv = new FreeMarkerView(); + fv.setApplicationContext(this.context); + + MockServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, new URI("/path")); + this.response = new MockServerHttpResponse(); + WebSessionManager manager = new DefaultWebSessionManager(); + this.exchange = new DefaultServerWebExchange(request, response, manager); + } + + + @Test + public void noFreeMarkerConfig() throws Exception { + this.exception.expect(ApplicationContextException.class); + this.exception.expectMessage("Must define a single FreeMarkerConfig bean"); + + FreeMarkerView view = new FreeMarkerView(); + view.setApplicationContext(this.context); + view.setUrl("anythingButNull"); + view.afterPropertiesSet(); + } + + @Test + public void noTemplateName() throws Exception { + this.exception.expect(IllegalArgumentException.class); + this.exception.expectMessage("Property 'url' is required"); + + FreeMarkerView freeMarkerView = new FreeMarkerView(); + freeMarkerView.afterPropertiesSet(); + } + + @Test + public void checkResourceExists() throws Exception { + FreeMarkerView view = new FreeMarkerView(); + view.setConfiguration(this.freeMarkerConfig); + view.setUrl("test.ftl"); + + assertTrue(view.checkResourceExists(Locale.US)); + } + + @Test + public void render() throws Exception { + FreeMarkerView view = new FreeMarkerView(); + view.setConfiguration(this.freeMarkerConfig); + view.setUrl("test.ftl"); + + ModelMap model = new ExtendedModelMap(); + model.addAttribute("hello", "hi FreeMarker"); + MethodParameter returnType = new MethodParameter(getClass().getDeclaredMethod("handle"), -1); + HandlerResult result = new HandlerResult(new Object(), "", returnType, model); + view.render(result, null, this.exchange); + + TestSubscriber + .subscribe(this.response.getBody()) + .assertValuesWith(dataBuffer -> + assertEquals("hi FreeMarker", asString(dataBuffer))); + } + + + private static String asString(DataBuffer dataBuffer) { + ByteBuffer byteBuffer = dataBuffer.asByteBuffer(); + final byte[] bytes = new byte[byteBuffer.remaining()]; + byteBuffer.get(bytes); + return new String(bytes, UTF_8); + } + + + @SuppressWarnings("unused") + private String handle() { + return null; + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/server/handler/ExceptionHandlingHttpHandlerTests.java b/spring-web-reactive/src/test/java/org/springframework/web/server/handler/ExceptionHandlingHttpHandlerTests.java new file mode 100644 index 0000000000..c3b9aef394 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/server/handler/ExceptionHandlingHttpHandlerTests.java @@ -0,0 +1,147 @@ +/* + * Copyright 2002-2015 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.server.handler; + + +import java.net.URI; + +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Mono; + +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebExceptionHandler; +import org.springframework.web.server.WebHandler; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; +import org.springframework.web.server.session.WebSessionManager; + +import static org.junit.Assert.assertEquals; + +/** + * @author Rossen Stoyanchev + */ +@SuppressWarnings("ThrowableResultOfMethodCallIgnored") +public class ExceptionHandlingHttpHandlerTests { + + private MockServerHttpResponse response; + + private ServerWebExchange exchange; + + private WebHandler targetHandler; + + + @Before + public void setUp() throws Exception { + URI uri = new URI("http://localhost:8080"); + WebSessionManager sessionManager = new MockWebSessionManager(); + MockServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, uri); + this.response = new MockServerHttpResponse(); + this.exchange = new DefaultServerWebExchange(request, this.response, sessionManager); + this.targetHandler = new StubWebHandler(new IllegalStateException("boo")); + } + + + @Test + public void handleErrorSignal() throws Exception { + WebExceptionHandler exceptionHandler = new BadRequestExceptionHandler(); + createWebHandler(exceptionHandler).handle(this.exchange).block(); + + assertEquals(HttpStatus.BAD_REQUEST, this.response.getStatusCode()); + } + + @Test + public void handleErrorSignalWithMultipleHttpErrorHandlers() throws Exception { + WebExceptionHandler[] exceptionHandlers = new WebExceptionHandler[] { + new UnresolvedExceptionHandler(), + new UnresolvedExceptionHandler(), + new BadRequestExceptionHandler(), + new UnresolvedExceptionHandler() + }; + createWebHandler(exceptionHandlers).handle(this.exchange).block(); + + assertEquals(HttpStatus.BAD_REQUEST, this.response.getStatusCode()); + } + + @Test + public void unresolvedException() throws Exception { + WebExceptionHandler exceptionHandler = new UnresolvedExceptionHandler(); + createWebHandler(exceptionHandler).handle(this.exchange).block(); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, this.response.getStatusCode()); + } + + @Test + public void thrownExceptionBecomesErrorSignal() throws Exception { + WebExceptionHandler exceptionHandler = new BadRequestExceptionHandler(); + createWebHandler(exceptionHandler).handle(this.exchange).block(); + + assertEquals(HttpStatus.BAD_REQUEST, this.response.getStatusCode()); + } + + private WebHandler createWebHandler(WebExceptionHandler... handlers) { + return new ExceptionHandlingWebHandler(this.targetHandler, handlers); + } + + + private static class StubWebHandler implements WebHandler { + + private final RuntimeException exception; + + private final boolean raise; + + + public StubWebHandler(RuntimeException exception) { + this(exception, false); + } + + public StubWebHandler(RuntimeException exception, boolean raise) { + this.exception = exception; + this.raise = raise; + } + + @Override + public Mono handle(ServerWebExchange exchange) { + if (this.raise) { + throw this.exception; + } + return Mono.error(this.exception); + } + } + + private static class BadRequestExceptionHandler implements WebExceptionHandler { + + @Override + public Mono handle(ServerWebExchange exchange, Throwable ex) { + exchange.getResponse().setStatusCode(HttpStatus.BAD_REQUEST); + return Mono.empty(); + } + } + + /** Leave the exception unresolved. */ + private static class UnresolvedExceptionHandler implements WebExceptionHandler { + + @Override + public Mono handle(ServerWebExchange exchange, Throwable ex) { + return Mono.error(ex); + } + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/server/handler/FilteringWebHandlerTests.java b/spring-web-reactive/src/test/java/org/springframework/web/server/handler/FilteringWebHandlerTests.java new file mode 100644 index 0000000000..4f6d54b9f0 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/server/handler/FilteringWebHandlerTests.java @@ -0,0 +1,209 @@ +/* + * Copyright 2002-2015 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.server.handler; + + +import java.net.URI; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Mono; + +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.reactive.HttpHandler; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebExceptionHandler; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import org.springframework.web.server.WebHandler; +import org.springframework.web.server.adapter.WebHttpHandlerBuilder; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * @author Rossen Stoyanchev + */ +public class FilteringWebHandlerTests { + + private static Log logger = LogFactory.getLog(FilteringWebHandlerTests.class); + + + private MockServerHttpRequest request; + + private MockServerHttpResponse response; + + + @Before + public void setUp() throws Exception { + this.request = new MockServerHttpRequest(HttpMethod.GET, new URI("http://localhost")); + this.response = new MockServerHttpResponse(); + } + + @Test + public void multipleFilters() throws Exception { + StubWebHandler webHandler = new StubWebHandler(); + TestFilter filter1 = new TestFilter(); + TestFilter filter2 = new TestFilter(); + TestFilter filter3 = new TestFilter(); + HttpHandler httpHandler = createHttpHandler(webHandler, filter1, filter2, filter3); + httpHandler.handle(this.request, this.response).block(); + + assertTrue(filter1.invoked()); + assertTrue(filter2.invoked()); + assertTrue(filter3.invoked()); + assertTrue(webHandler.invoked()); + } + + @Test + public void zeroFilters() throws Exception { + StubWebHandler webHandler = new StubWebHandler(); + HttpHandler httpHandler = createHttpHandler(webHandler); + httpHandler.handle(this.request, this.response).block(); + + assertTrue(webHandler.invoked()); + } + + @Test + public void shortcircuitFilter() throws Exception { + StubWebHandler webHandler = new StubWebHandler(); + TestFilter filter1 = new TestFilter(); + ShortcircuitingFilter filter2 = new ShortcircuitingFilter(); + TestFilter filter3 = new TestFilter(); + HttpHandler httpHandler = createHttpHandler(webHandler, filter1, filter2, filter3); + httpHandler.handle(this.request, this.response).block(); + + assertTrue(filter1.invoked()); + assertTrue(filter2.invoked()); + assertFalse(filter3.invoked()); + assertFalse(webHandler.invoked()); + } + + @Test + public void asyncFilter() throws Exception { + StubWebHandler webHandler = new StubWebHandler(); + AsyncFilter filter = new AsyncFilter(); + HttpHandler httpHandler = createHttpHandler(webHandler, filter); + httpHandler.handle(this.request, this.response).block(); + + assertTrue(filter.invoked()); + assertTrue(webHandler.invoked()); + } + + @Test + public void handleErrorFromFilter() throws Exception { + TestExceptionHandler exceptionHandler = new TestExceptionHandler(); + HttpHandler handler = WebHttpHandlerBuilder.webHandler(new StubWebHandler()) + .filters(new ExceptionFilter()).exceptionHandlers(exceptionHandler).build(); + handler.handle(this.request, this.response).block(); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, this.response.getStatusCode()); + + Throwable savedException = exceptionHandler.ex; + assertNotNull(savedException); + assertEquals("boo", savedException.getMessage()); + } + + private HttpHandler createHttpHandler(StubWebHandler webHandler, WebFilter... filters) { + return WebHttpHandlerBuilder.webHandler(webHandler).filters(filters).build(); + } + + + private static class TestFilter implements WebFilter { + + private volatile boolean invoked; + + public boolean invoked() { + return this.invoked; + } + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + this.invoked = true; + return doFilter(exchange, chain); + } + + public Mono doFilter(ServerWebExchange exchange, WebFilterChain chain) { + return chain.filter(exchange); + } + } + + private static class ShortcircuitingFilter extends TestFilter { + + @Override + public Mono doFilter(ServerWebExchange exchange, WebFilterChain chain) { + return Mono.empty(); + } + } + + private static class AsyncFilter extends TestFilter { + + @Override + public Mono doFilter(ServerWebExchange exchange, WebFilterChain chain) { + return doAsyncWork().then(asyncResult -> { + logger.debug("Async result: " + asyncResult); + return chain.filter(exchange); + }); + } + + private Mono doAsyncWork() { + return Mono.just("123"); + } + } + + private static class ExceptionFilter implements WebFilter { + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + return Mono.error(new IllegalStateException("boo")); + } + } + + private static class TestExceptionHandler implements WebExceptionHandler { + + private Throwable ex; + + @Override + public Mono handle(ServerWebExchange exchange, Throwable ex) { + this.ex = ex; + return Mono.error(ex); + } + } + + private static class StubWebHandler implements WebHandler { + + private volatile boolean invoked; + + public boolean invoked() { + return this.invoked; + } + + @Override + public Mono handle(ServerWebExchange exchange) { + logger.trace("StubHandler invoked."); + this.invoked = true; + return Mono.empty(); + } + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/server/session/DefaultWebSessionManagerTests.java b/spring-web-reactive/src/test/java/org/springframework/web/server/session/DefaultWebSessionManagerTests.java new file mode 100644 index 0000000000..297510b6cd --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/server/session/DefaultWebSessionManagerTests.java @@ -0,0 +1,161 @@ +/* + * Copyright 2002-2016 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.server.session; + +import java.net.URI; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.Before; +import org.junit.Test; + +import org.springframework.http.HttpMethod; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebSession; +import org.springframework.web.server.adapter.DefaultServerWebExchange; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; + +/** + * @author Rossen Stoyanchev + */ +public class DefaultWebSessionManagerTests { + + private final DefaultWebSessionManager manager = new DefaultWebSessionManager(); + + private final TestWebSessionIdResolver idResolver = new TestWebSessionIdResolver(); + + private DefaultServerWebExchange exchange; + + + @Before + public void setUp() throws Exception { + this.manager.setSessionIdResolver(this.idResolver); + + MockServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, new URI("/path")); + MockServerHttpResponse response = new MockServerHttpResponse(); + this.exchange = new DefaultServerWebExchange(request, response, this.manager); + } + + + @Test + public void getSessionWithoutStarting() throws Exception { + this.idResolver.setIdsToResolve(Collections.emptyList()); + WebSession session = this.manager.getSession(this.exchange).block(); + session.save(); + + assertFalse(session.isStarted()); + assertFalse(session.isExpired()); + assertNull(this.idResolver.getSavedId()); + assertNull(this.manager.getSessionStore().retrieveSession(session.getId()).block()); + } + + @Test + public void startSessionExplicitly() throws Exception { + this.idResolver.setIdsToResolve(Collections.emptyList()); + WebSession session = this.manager.getSession(this.exchange).block(); + session.start(); + session.save(); + + String id = session.getId(); + assertNotNull(this.idResolver.getSavedId()); + assertEquals(id, this.idResolver.getSavedId()); + assertSame(session, this.manager.getSessionStore().retrieveSession(id).block()); + } + + @Test + public void startSessionImplicitly() throws Exception { + this.idResolver.setIdsToResolve(Collections.emptyList()); + WebSession session = this.manager.getSession(this.exchange).block(); + session.getAttributes().put("foo", "bar"); + session.save(); + + assertNotNull(this.idResolver.getSavedId()); + } + + @Test + public void existingSession() throws Exception { + DefaultWebSession existing = new DefaultWebSession("1", Clock.systemDefaultZone()); + this.manager.getSessionStore().storeSession(existing); + this.idResolver.setIdsToResolve(Collections.singletonList("1")); + + WebSession actual = this.manager.getSession(this.exchange).block(); + assertSame(existing, actual); + } + + @Test + public void existingSessionIsExpired() throws Exception { + Clock clock = Clock.systemDefaultZone(); + DefaultWebSession existing = new DefaultWebSession("1", clock); + existing.start(); + existing.setLastAccessTime(Instant.now(clock).minus(Duration.ofMinutes(31))); + this.manager.getSessionStore().storeSession(existing); + this.idResolver.setIdsToResolve(Collections.singletonList("1")); + + WebSession actual = this.manager.getSession(this.exchange).block(); + assertNotSame(existing, actual); + } + + @Test + public void multipleSessions() throws Exception { + DefaultWebSession existing = new DefaultWebSession("3", Clock.systemDefaultZone()); + this.manager.getSessionStore().storeSession(existing); + this.idResolver.setIdsToResolve(Arrays.asList("1", "2", "3")); + + WebSession actual = this.manager.getSession(this.exchange).block(); + assertSame(existing, actual); + } + + + private static class TestWebSessionIdResolver implements WebSessionIdResolver { + + private List idsToResolve = new ArrayList<>(); + + private String id = null; + + + public void setIdsToResolve(List idsToResolve) { + this.idsToResolve = idsToResolve; + } + + public String getSavedId() { + return this.id; + } + + @Override + public List resolveSessionIds(ServerWebExchange exchange) { + return this.idsToResolve; + } + + @Override + public void setSessionId(ServerWebExchange exchange, String sessionId) { + this.id = sessionId; + } + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/server/session/MockWebSessionManager.java b/spring-web-reactive/src/test/java/org/springframework/web/server/session/MockWebSessionManager.java new file mode 100644 index 0000000000..5697d40f84 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/server/session/MockWebSessionManager.java @@ -0,0 +1,51 @@ +/* + * Copyright 2002-2016 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.server.session; + +import reactor.core.publisher.Mono; + +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebSession; + +/** + * Mock implementation of {@link WebSessionManager}. + * + * @author Rossen Stoyanchev + */ +public class MockWebSessionManager implements WebSessionManager { + + private final Mono session; + + + public MockWebSessionManager() { + this(Mono.empty()); + } + + public MockWebSessionManager(WebSession session) { + this(Mono.just(session)); + } + + public MockWebSessionManager(Mono session) { + this.session = session; + } + + + @Override + public Mono getSession(ServerWebExchange exchange) { + return this.session; + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/server/session/WebSessionIntegrationTests.java b/spring-web-reactive/src/test/java/org/springframework/web/server/session/WebSessionIntegrationTests.java new file mode 100644 index 0000000000..fc2df4716f --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/server/session/WebSessionIntegrationTests.java @@ -0,0 +1,167 @@ +/* + * Copyright 2002-2016 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.server.session; + +import java.net.URI; +import java.net.URISyntaxException; +import java.time.Clock; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.Test; +import reactor.core.publisher.Mono; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.http.server.reactive.AbstractHttpHandlerIntegrationTests; +import org.springframework.http.server.reactive.HttpHandler; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.server.WebHandler; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.adapter.WebHttpHandlerBuilder; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +/** + * Integration tests for with a server-side session. + * + * @author Rossen Stoyanchev + */ +public class WebSessionIntegrationTests extends AbstractHttpHandlerIntegrationTests { + + private RestTemplate restTemplate; + + private DefaultWebSessionManager sessionManager; + + private TestWebHandler handler; + + + @Override + public void setup() throws Exception { + super.setup(); + this.restTemplate = new RestTemplate(); + } + + protected URI createUri(String pathAndQuery) throws URISyntaxException { + boolean prefix = !StringUtils.hasText(pathAndQuery) || !pathAndQuery.startsWith("/"); + pathAndQuery = (prefix ? "/" + pathAndQuery : pathAndQuery); + return new URI("http://localhost:" + port + pathAndQuery); + } + + @Override + protected HttpHandler createHttpHandler() { + this.sessionManager = new DefaultWebSessionManager(); + this.handler = new TestWebHandler(); + return WebHttpHandlerBuilder.webHandler(this.handler).sessionManager(this.sessionManager).build(); + } + + @Test + public void createSession() throws Exception { + RequestEntity request = RequestEntity.get(createUri("/")).build(); + ResponseEntity response = this.restTemplate.exchange(request, Void.class); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + String id = extractSessionId(response.getHeaders()); + assertNotNull(id); + assertEquals(1, this.handler.getCount()); + + request = RequestEntity.get(createUri("/")).header("Cookie", "SESSION=" + id).build(); + response = this.restTemplate.exchange(request, Void.class); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNull(response.getHeaders().get("Set-Cookie")); + assertEquals(2, this.handler.getCount()); + } + + @Test + public void expiredSession() throws Exception { + + // First request: no session yet, new session created + RequestEntity request = RequestEntity.get(createUri("/")).build(); + ResponseEntity response = this.restTemplate.exchange(request, Void.class); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + String id = extractSessionId(response.getHeaders()); + assertNotNull(id); + assertEquals(1, this.handler.getCount()); + + // Set (server-side) clock back 31 minutes + Clock clock = this.sessionManager.getClock(); + this.sessionManager.setClock(Clock.offset(clock, Duration.ofMinutes(-31))); + + // Second request: lastAccessTime updated (with offset clock) + request = RequestEntity.get(createUri("/")).header("Cookie", "SESSION=" + id).build(); + response = this.restTemplate.exchange(request, Void.class); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNull(response.getHeaders().get("Set-Cookie")); + assertEquals(2, this.handler.getCount()); + + // Third request: new session replaces expired session + request = RequestEntity.get(createUri("/")).header("Cookie", "SESSION=" + id).build(); + response = this.restTemplate.exchange(request, Void.class); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + id = extractSessionId(response.getHeaders()); + assertNotNull("Expected new session id", id); + assertEquals("Expected new session attribute", 1, this.handler.getCount()); + } + + private String extractSessionId(HttpHeaders headers) { + List headerValues = headers.get("Set-Cookie"); + assertNotNull(headerValues); + assertEquals(1, headerValues.size()); + + List data = new ArrayList<>(); + for (String s : headerValues.get(0).split(";")){ + if (s.startsWith("SESSION=")) { + return s.substring("SESSION=".length()); + } + } + return null; + } + + private static class TestWebHandler implements WebHandler { + + private AtomicInteger currentValue = new AtomicInteger(); + + + public int getCount() { + return this.currentValue.get(); + } + + @Override + public Mono handle(ServerWebExchange exchange) { + return exchange.getSession().map(session -> { + Map map = session.getAttributes(); + int value = (map.get("counter") != null ? (int) map.get("counter") : 0); + value++; + map.put("counter", value); + this.currentValue.set(value); + return session; + }).then(); + } + } + +} diff --git a/spring-web-reactive/src/test/resources/log4j.properties b/spring-web-reactive/src/test/resources/log4j.properties new file mode 100644 index 0000000000..34659ab78b --- /dev/null +++ b/spring-web-reactive/src/test/resources/log4j.properties @@ -0,0 +1,9 @@ +log4j.rootCategory=WARN, stdout + +log4j.logger.org.springframework.http=DEBUG +log4j.logger.org.springframework.web=DEBUG +log4j.logger.reactor=INFO + +log4j.appender.stdout=org.apache.log4j.ConsoleAppender +log4j.appender.stdout.layout=org.apache.log4j.PatternLayout +log4j.appender.stdout.layout.ConversionPattern=%d %p [%c] <%t> - %m%n \ No newline at end of file diff --git a/spring-web-reactive/src/test/resources/org/springframework/core/io/buffer/support/DataBufferUtilsTests.txt b/spring-web-reactive/src/test/resources/org/springframework/core/io/buffer/support/DataBufferUtilsTests.txt new file mode 100644 index 0000000000..ab9b661144 --- /dev/null +++ b/spring-web-reactive/src/test/resources/org/springframework/core/io/buffer/support/DataBufferUtilsTests.txt @@ -0,0 +1,4 @@ +foo +bar +baz +qux diff --git a/spring-web-reactive/src/test/resources/org/springframework/http/server/reactive/spring.png b/spring-web-reactive/src/test/resources/org/springframework/http/server/reactive/spring.png new file mode 100644 index 0000000000..2fec781a5e Binary files /dev/null and b/spring-web-reactive/src/test/resources/org/springframework/http/server/reactive/spring.png differ diff --git a/spring-web-reactive/src/test/resources/org/springframework/web/reactive/handler/map.xml b/spring-web-reactive/src/test/resources/org/springframework/web/reactive/handler/map.xml new file mode 100644 index 0000000000..be0f6f067f --- /dev/null +++ b/spring-web-reactive/src/test/resources/org/springframework/web/reactive/handler/map.xml @@ -0,0 +1,40 @@ + + + + + + + + welcome.html=mainController + /**/pathmatchingTest.html=mainController + /**/pathmatching??.html=mainController + /**/path??matching.html=mainController + /**/??path??matching.html=mainController + /**/*.jsp=mainController + /administrator/**/pathmatching.html=mainController + /administrator/**/testlast*=mainController + /administrator/another/bla.xml=mainController + /administrator/testing/longer/**/**/**/**/**=mainController + /administrator/testing/longer2/**/**/bla/**=mainController + /*test*.jpeg=mainController + /*/test.jpeg=mainController + /outofpattern*yeah=mainController + /anotherTest*=mainController + /stillAnotherTestYeah=mainController + /shortpattern/testing=mainController + /show123.html=mainController + /sho*=mainController + /bookseats.html=mainController + /reservation.html=mainController + /payment.html=mainController + /confirmation.html=mainController + /test%26t%20est/path%26m%20atching.html=mainController + + + + + + + diff --git a/spring-web-reactive/src/test/resources/org/springframework/web/reactive/result/method/annotation/logo.png b/spring-web-reactive/src/test/resources/org/springframework/web/reactive/result/method/annotation/logo.png new file mode 100644 index 0000000000..10cc93fbcf Binary files /dev/null and b/spring-web-reactive/src/test/resources/org/springframework/web/reactive/result/method/annotation/logo.png differ diff --git a/spring-web-reactive/src/test/resources/org/springframework/web/reactive/result/view/freemarker/test.ftl b/spring-web-reactive/src/test/resources/org/springframework/web/reactive/result/view/freemarker/test.ftl new file mode 100644 index 0000000000..f9ad1fdc6e --- /dev/null +++ b/spring-web-reactive/src/test/resources/org/springframework/web/reactive/result/view/freemarker/test.ftl @@ -0,0 +1 @@ +${hello} \ No newline at end of file