diff --git a/.gitignore b/.gitignore index 3f904904f76f..b08c4536d776 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ out test-output atlassian-ide-plugin.xml .gradletasknamecache + +# VS Code +.vscode/ \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2295a0928826..a236224cb58d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -125,10 +125,7 @@ The reference documentation is in the [src/docs/asciidoc](src/docs/asciidoc) dir edit source files, and submit directly from GitHub. When making changes locally, execute `./gradlew asciidoctor` and then browse the result under -`build/asciidoc/html5/index.html`. +`build/docs/ref-docs/html5/index.html`. -Asciidoctor also supports live editing. For more details read -[Editing AsciiDoc with Live Preview](https://asciidoctor.org/docs/editing-asciidoc-with-live-preview/). -Note that if you choose the -[System Monitor](https://asciidoctor.org/docs/editing-asciidoc-with-live-preview/#using-a-system-monitor) -option, you can find a Guardfile under `src/docs/asciidoc`. +Asciidoctor also supports live editing. For more details see +[AsciiDoc Tooling](https://docs.asciidoctor.org/asciidoctor/latest/tooling/). diff --git a/README.md b/README.md index 4f90a5aa27a0..d3d16d18d118 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,10 @@ See the [Micro-Benchmarks](https://github.com/spring-projects/spring-framework/w See the [Build from Source](https://github.com/spring-projects/spring-framework/wiki/Build-from-Source) Wiki page and the [CONTRIBUTING.md](CONTRIBUTING.md) file. +## Continuous Integration Builds + +Information regarding CI builds can be found in the [Spring Framework Concourse pipeline](ci/README.adoc) documentation. + ## Stay in Touch Follow [@SpringCentral](https://twitter.com/springcentral), [@SpringFramework](https://twitter.com/springframework), and its [team members](https://twitter.com/springframework/lists/team/members) on Twitter. In-depth articles can be found at [The Spring Blog](https://spring.io/blog/), and releases are announced via our [news feed](https://spring.io/blog/category/news). diff --git a/build.gradle b/build.gradle index 29fcdaf959a2..88324bc313b4 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ plugins { id 'io.spring.dependency-management' version '1.0.9.RELEASE' apply false id 'io.spring.nohttp' version '0.0.5.RELEASE' - id 'org.jetbrains.kotlin.jvm' version '1.4.30' apply false + id 'org.jetbrains.kotlin.jvm' version '1.4.32' apply false id 'org.jetbrains.dokka' version '0.10.1' apply false id 'org.asciidoctor.jvm.convert' version '3.1.0' id 'org.asciidoctor.jvm.pdf' version '3.1.0' @@ -10,7 +10,7 @@ plugins { id "com.github.ben-manes.versions" version '0.28.0' id "com.github.johnrengelman.shadow" version "6.1.0" apply false id "me.champeau.gradle.jmh" version "0.5.2" apply false - id "org.jetbrains.kotlin.plugin.serialization" version "1.4.30" apply false + id "org.jetbrains.kotlin.plugin.serialization" version "1.4.32" apply false } ext { @@ -26,18 +26,18 @@ configure(allprojects) { project -> dependencyManagement { imports { - mavenBom "com.fasterxml.jackson:jackson-bom:2.12.1" - mavenBom "io.netty:netty-bom:4.1.59.Final" - mavenBom "io.projectreactor:reactor-bom:2020.0.4" - mavenBom "io.r2dbc:r2dbc-bom:Arabba-SR8" + mavenBom "com.fasterxml.jackson:jackson-bom:2.12.2" + mavenBom "io.netty:netty-bom:4.1.63.Final" + mavenBom "io.projectreactor:reactor-bom:2020.0.6" + mavenBom "io.r2dbc:r2dbc-bom:Arabba-SR9" mavenBom "io.rsocket:rsocket-bom:1.1.0" - mavenBom "org.eclipse.jetty:jetty-bom:9.4.36.v20210114" - mavenBom "org.jetbrains.kotlin:kotlin-bom:1.4.30" - mavenBom "org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.4.2" + mavenBom "org.eclipse.jetty:jetty-bom:9.4.39.v20210325" + mavenBom "org.jetbrains.kotlin:kotlin-bom:1.4.32" + mavenBom "org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.4.3" mavenBom "org.junit:junit-bom:5.7.1" } dependencies { - dependencySet(group: 'org.apache.logging.log4j', version: '2.14.0') { + dependencySet(group: 'org.apache.logging.log4j', version: '2.14.1') { entry 'log4j-api' entry 'log4j-core' entry 'log4j-jul' @@ -65,16 +65,16 @@ configure(allprojects) { project -> dependency "io.reactivex:rxjava:1.3.8" dependency "io.reactivex:rxjava-reactive-streams:1.2.1" dependency "io.reactivex.rxjava2:rxjava:2.2.21" - dependency "io.reactivex.rxjava3:rxjava:3.0.10" + dependency "io.reactivex.rxjava3:rxjava:3.0.12" dependency "io.projectreactor.tools:blockhound:1.0.4.RELEASE" dependency "com.caucho:hessian:4.0.63" dependency "com.fasterxml:aalto-xml:1.2.2" - dependency("com.fasterxml.woodstox:woodstox-core:6.2.3") { + dependency("com.fasterxml.woodstox:woodstox-core:6.2.4") { exclude group: "stax", name: "stax-api" } dependency "com.google.code.gson:gson:2.8.6" - dependency "com.google.protobuf:protobuf-java-util:3.14.0" + dependency "com.google.protobuf:protobuf-java-util:3.15.5" dependency "com.googlecode.protobuf-java-format:protobuf-java-format:1.4" dependency("com.thoughtworks.xstream:xstream:1.4.15") { exclude group: "xpp3", name: "xpp3_min" @@ -89,20 +89,20 @@ configure(allprojects) { project -> entry 'jibx-run' } dependency "org.ogce:xpp3:1.1.6" - dependency "org.yaml:snakeyaml:1.27" - dependencySet(group: 'org.jetbrains.kotlinx', version: '1.0.0') { + dependency "org.yaml:snakeyaml:1.28" + dependencySet(group: 'org.jetbrains.kotlinx', version: '1.0.1') { entry 'kotlinx-serialization-core' entry 'kotlinx-serialization-json' } dependency "com.h2database:h2:1.4.200" - dependency "com.github.ben-manes.caffeine:caffeine:2.8.8" + dependency "com.github.ben-manes.caffeine:caffeine:2.9.0" dependency "com.github.librepdf:openpdf:1.3.25" dependency "com.rometools:rome:1.15.0" dependency "commons-io:commons-io:2.5" dependency "io.vavr:vavr:0.10.3" dependency "net.sf.jopt-simple:jopt-simple:5.0.4" - dependencySet(group: 'org.apache.activemq', version: '5.16.0') { + dependencySet(group: 'org.apache.activemq', version: '5.16.1') { entry 'activemq-broker' entry('activemq-kahadb-store') { exclude group: "org.springframework", name: "spring-context" @@ -117,30 +117,30 @@ configure(allprojects) { project -> } dependency "org.apache.poi:poi-ooxml:4.1.2" dependency "org.apache-extras.beanshell:bsh:2.0b6" - dependency "org.freemarker:freemarker:2.3.30" + dependency "org.freemarker:freemarker:2.3.31" dependency "org.hsqldb:hsqldb:2.5.1" dependency "org.quartz-scheduler:quartz:2.3.2" dependency "org.codehaus.fabric3.api:commonj:1.1.0" dependency "net.sf.ehcache:ehcache:2.10.6" dependency "org.ehcache:jcache:1.0.1" dependency "org.ehcache:ehcache:3.4.0" - dependency "org.hibernate:hibernate-core:5.4.28.Final" + dependency "org.hibernate:hibernate-core:5.4.30.Final" dependency "org.hibernate:hibernate-validator:6.2.0.Final" dependency "org.webjars:webjars-locator-core:0.46" dependency "org.webjars:underscorejs:1.8.3" - dependencySet(group: 'org.apache.tomcat', version: '9.0.43') { + dependencySet(group: 'org.apache.tomcat', version: '9.0.45') { entry 'tomcat-util' entry('tomcat-websocket') { exclude group: "org.apache.tomcat", name: "tomcat-websocket-api" exclude group: "org.apache.tomcat", name: "tomcat-servlet-api" } } - dependencySet(group: 'org.apache.tomcat.embed', version: '9.0.43') { + dependencySet(group: 'org.apache.tomcat.embed', version: '9.0.45') { entry 'tomcat-embed-core' entry 'tomcat-embed-websocket' } - dependencySet(group: 'io.undertow', version: '2.2.4.Final') { + dependencySet(group: 'io.undertow', version: '2.2.7.Final') { entry 'undertow-core' entry('undertow-websockets-jsr') { exclude group: "org.jboss.spec.javax.websocket", name: "jboss-websocket-api_1.1_spec" @@ -163,9 +163,9 @@ configure(allprojects) { project -> } dependency 'org.apache.httpcomponents.client5:httpclient5:5.0.3' dependency 'org.apache.httpcomponents.core5:httpcore5-reactive:5.0.3' - dependency "org.eclipse.jetty:jetty-reactive-httpclient:1.1.5" + dependency "org.eclipse.jetty:jetty-reactive-httpclient:1.1.6" - dependency "org.jruby:jruby:9.2.13.0" + dependency "org.jruby:jruby:9.2.16.0" dependency "org.python:jython-standalone:2.7.1" dependency "org.mozilla:rhino:1.7.11" @@ -188,7 +188,7 @@ configure(allprojects) { project -> dependency("de.bechte.junit:junit-hierarchicalcontextrunner:4.12.1") { exclude group: "junit", name: "junit" } - dependency "org.testng:testng:7.3.0" + dependency "org.testng:testng:7.4.0" dependency "org.hamcrest:hamcrest:2.1" dependency "org.awaitility:awaitility:3.1.6" dependency "org.assertj:assertj-core:3.19.0" @@ -198,7 +198,7 @@ configure(allprojects) { project -> exclude group: "org.hamcrest", name: "hamcrest-core" } } - dependencySet(group: 'org.mockito', version: '3.7.7') { + dependencySet(group: 'org.mockito', version: '3.8.0') { entry('mockito-core') { exclude group: "org.hamcrest", name: "hamcrest-core" } @@ -206,10 +206,10 @@ configure(allprojects) { project -> } dependency "io.mockk:mockk:1.10.2" - dependency("net.sourceforge.htmlunit:htmlunit:2.47.1") { + dependency("net.sourceforge.htmlunit:htmlunit:2.48.0") { exclude group: "commons-logging", name: "commons-logging" } - dependency("org.seleniumhq.selenium:htmlunit-driver:2.47.1") { + dependency("org.seleniumhq.selenium:htmlunit-driver:2.48.0") { exclude group: "commons-logging", name: "commons-logging" } dependency("org.seleniumhq.selenium:selenium-java:3.141.59") { @@ -217,7 +217,7 @@ configure(allprojects) { project -> exclude group: "io.netty", name: "netty" } dependency "org.skyscreamer:jsonassert:1.5.0" - dependency "com.jayway.jsonpath:json-path:2.4.0" + dependency "com.jayway.jsonpath:json-path:2.5.0" dependency "org.bouncycastle:bcpkix-jdk15on:1.66" dependencySet(group: 'org.apache.tiles', version: '3.0.8') { @@ -309,14 +309,13 @@ configure([rootProject] + javaProjects) { project -> apply plugin: "java-test-fixtures" apply plugin: "checkstyle" apply plugin: 'org.springframework.build.compile' - apply from: "${rootDir}/gradle/custom-java-home.gradle" + apply from: "${rootDir}/gradle/toolchains.gradle" apply from: "${rootDir}/gradle/ide.gradle" pluginManager.withPlugin("kotlin") { apply plugin: "org.jetbrains.dokka" compileKotlin { kotlinOptions { - jvmTarget = "1.8" languageVersion = "1.3" apiVersion = "1.3" freeCompilerArgs = ["-Xjsr305=strict"] @@ -325,7 +324,6 @@ configure([rootProject] + javaProjects) { project -> } compileTestKotlin { kotlinOptions { - jvmTarget = "1.8" freeCompilerArgs = ["-Xjsr305=strict"] } } @@ -340,7 +338,7 @@ configure([rootProject] + javaProjects) { project -> } checkstyle { - toolVersion = "8.39" + toolVersion = "8.41" configDirectory.set(rootProject.file("src/checkstyle")) } diff --git a/buildSrc/README.md b/buildSrc/README.md index f48339e6d61f..ada387cf7144 100644 --- a/buildSrc/README.md +++ b/buildSrc/README.md @@ -8,16 +8,10 @@ They are declared in the `build.gradle` file in this folder. ### Compiler conventions The `org.springframework.build.compile` plugin applies the Java compiler conventions to the build. -By default, the build compiles sources with Java `1.8` source and target compatibility. -You can test a different source compatibility version on the CLI with a project property like: - -``` -./gradlew test -PjavaSourceVersion=11 -``` ## Build Plugins -## Optional dependencies +### Optional dependencies The `org.springframework.build.optional-dependencies` plugin creates a new `optional` Gradle configuration - it adds the dependencies to the project's compile and runtime classpath @@ -25,7 +19,7 @@ but doesn't affect the classpath of dependent projects. This plugin does not provide a `provided` configuration, as the native `compileOnly` and `testCompileOnly` configurations are preferred. -## API Diff +### API Diff This plugin uses the [Gradle JApiCmp](https://github.com/melix/japicmp-gradle-plugin) plugin to generate API Diff reports for each Spring Framework module. This plugin is applied once on the root diff --git a/buildSrc/src/main/java/org/springframework/build/compile/CompilerConventionsPlugin.java b/buildSrc/src/main/java/org/springframework/build/compile/CompilerConventionsPlugin.java index db51666f74b5..5284df28f124 100644 --- a/buildSrc/src/main/java/org/springframework/build/compile/CompilerConventionsPlugin.java +++ b/buildSrc/src/main/java/org/springframework/build/compile/CompilerConventionsPlugin.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,31 +20,21 @@ import java.util.Arrays; import java.util.List; -import org.gradle.api.JavaVersion; import org.gradle.api.Plugin; import org.gradle.api.Project; +import org.gradle.api.plugins.JavaLibraryPlugin; import org.gradle.api.plugins.JavaPlugin; import org.gradle.api.plugins.JavaPluginConvention; import org.gradle.api.tasks.compile.JavaCompile; /** * {@link Plugin} that applies conventions for compiling Java sources in Spring Framework. - *

One can override the default Java source compatibility version - * with a dedicated property on the CLI: {@code "./gradlew test -PjavaSourceVersion=11"}. * * @author Brian Clozel * @author Sam Brannen */ public class CompilerConventionsPlugin implements Plugin { - /** - * The project property that can be used to switch the Java source - * compatibility version for building source and test classes. - */ - public static final String JAVA_SOURCE_VERSION_PROPERTY = "javaSourceVersion"; - - public static final JavaVersion DEFAULT_COMPILER_VERSION = JavaVersion.VERSION_1_8; - private static final List COMPILER_ARGS; private static final List TEST_COMPILER_ARGS; @@ -69,7 +59,7 @@ public class CompilerConventionsPlugin implements Plugin { @Override public void apply(Project project) { - project.getPlugins().withType(JavaPlugin.class, javaPlugin -> applyJavaCompileConventions(project)); + project.getPlugins().withType(JavaLibraryPlugin.class, javaPlugin -> applyJavaCompileConventions(project)); } /** @@ -79,15 +69,6 @@ public void apply(Project project) { */ private void applyJavaCompileConventions(Project project) { JavaPluginConvention java = project.getConvention().getPlugin(JavaPluginConvention.class); - if (project.hasProperty(JAVA_SOURCE_VERSION_PROPERTY)) { - JavaVersion javaSourceVersion = JavaVersion.toVersion(project.property(JAVA_SOURCE_VERSION_PROPERTY)); - java.setSourceCompatibility(javaSourceVersion); - } - else { - java.setSourceCompatibility(DEFAULT_COMPILER_VERSION); - } - java.setTargetCompatibility(DEFAULT_COMPILER_VERSION); - project.getTasks().withType(JavaCompile.class) .matching(compileTask -> compileTask.getName().equals(JavaPlugin.COMPILE_JAVA_TASK_NAME)) .forEach(compileTask -> { diff --git a/ci/README.adoc b/ci/README.adoc index c2c154acca90..cb617637d9b4 100644 --- a/ci/README.adoc +++ b/ci/README.adoc @@ -1,7 +1,8 @@ == Spring Framework Concourse pipeline -The Spring Framework is using https://concourse-ci.org/[Concourse] for its CI build and other automated tasks. -The Spring team has a dedicated Concourse instance available at https://ci.spring.io. +The Spring Framework uses https://concourse-ci.org/[Concourse] for its CI build and other automated tasks. +The Spring team has a dedicated Concourse instance available at https://ci.spring.io with a build pipeline +for https://ci.spring.io/teams/spring-framework/pipelines/spring-framework-5.3.x[Spring Framework 5.3.x]. === Setting up your development environment @@ -25,13 +26,17 @@ spring https://ci.spring.io spring-framework Wed, 25 Mar 20 ---- === Pipeline configuration and structure + The build pipelines are described in `pipeline.yml` file. + This file is listing Concourse resources, i.e. build inputs and outputs such as container images, artifact repositories, source repositories, notification services, etc. + It also describes jobs (a job is a sequence of inputs, tasks and outputs); jobs are organized by groups. The `pipeline.yml` definition contains `((parameters))` which are loaded from the `parameters.yml` file or from our https://docs.cloudfoundry.org/credhub/[credhub instance]. You'll find in this folder the following resources: + * `pipeline.yml` the build pipeline * `parameters.yml` the build parameters used for the pipeline * `images/` holds the container images definitions used in this pipeline @@ -41,6 +46,7 @@ You'll find in this folder the following resources: === Updating the build pipeline Updating files on the repository is not enough to update the build pipeline, as changes need to be applied. + The pipeline can be deployed using the following command: [source] @@ -48,4 +54,4 @@ The pipeline can be deployed using the following command: $ fly -t spring set-pipeline -p spring-framework-5.3.x -c ci/pipeline.yml -l ci/parameters.yml ---- -NOTE: This assumes that you have credhub integration configured with the appropriate secrets. \ No newline at end of file +NOTE: This assumes that you have credhub integration configured with the appropriate secrets. diff --git a/ci/config/release-scripts.yml b/ci/config/release-scripts.yml index 1e70c90e6886..d31f8cba00dc 100644 --- a/ci/config/release-scripts.yml +++ b/ci/config/release-scripts.yml @@ -1,9 +1,10 @@ logging: level: io.spring.concourse: DEBUG -distribute: - optional-deployments: - - '.*\.zip' spring: main: banner-mode: off +sonatype: + exclude: + - 'build-info\.json' + - '.*\.zip' diff --git a/ci/images/spring-framework-ci-image/Dockerfile b/ci/images/ci-image/Dockerfile similarity index 59% rename from ci/images/spring-framework-ci-image/Dockerfile rename to ci/images/ci-image/Dockerfile index 2952402431ac..1ce41f37a932 100644 --- a/ci/images/spring-framework-ci-image/Dockerfile +++ b/ci/images/ci-image/Dockerfile @@ -4,5 +4,8 @@ ADD setup.sh /setup.sh ADD get-jdk-url.sh /get-jdk-url.sh RUN ./setup.sh java8 -ENV JAVA_HOME /opt/openjdk +ENV JAVA_HOME /opt/openjdk/java8 +ENV JDK11 /opt/openjdk/java11 +ENV JDK15 /opt/openjdk/java15 + ENV PATH $JAVA_HOME/bin:$PATH diff --git a/ci/images/setup.sh b/ci/images/setup.sh index 9942d5acc127..b226ab7de75c 100755 --- a/ci/images/setup.sh +++ b/ci/images/setup.sh @@ -12,20 +12,27 @@ ln -fs /usr/share/zoneinfo/UTC /etc/localtime dpkg-reconfigure --frontend noninteractive tzdata rm -rf /var/lib/apt/lists/* -curl https://raw.githubusercontent.com/spring-io/concourse-java-scripts/v0.0.3/concourse-java.sh > /opt/concourse-java.sh +curl https://raw.githubusercontent.com/spring-io/concourse-java-scripts/v0.0.4/concourse-java.sh > /opt/concourse-java.sh -curl --output /opt/concourse-release-scripts.jar https://repo.spring.io/release/io/spring/concourse/releasescripts/concourse-release-scripts/0.2.1/concourse-release-scripts-0.2.1.jar +curl --output /opt/concourse-release-scripts.jar https://repo.spring.io/release/io/spring/concourse/releasescripts/concourse-release-scripts/0.3.2/concourse-release-scripts-0.3.2.jar ########################################################### # JAVA ########################################################### -JDK_URL=$( ./get-jdk-url.sh $1 ) mkdir -p /opt/openjdk -cd /opt/openjdk -curl -L ${JDK_URL} | tar zx --strip-components=1 -test -f /opt/openjdk/bin/java -test -f /opt/openjdk/bin/javac +pushd /opt/openjdk > /dev/null +for jdk in java8 java11 java15 +do + JDK_URL=$( /get-jdk-url.sh $jdk ) + mkdir $jdk + pushd $jdk > /dev/null + curl -L ${JDK_URL} | tar zx --strip-components=1 + test -f bin/java + test -f bin/javac + popd > /dev/null +done +popd ########################################################### # GRADLE ENTERPRISE diff --git a/ci/images/spring-framework-jdk11-ci-image/Dockerfile b/ci/images/spring-framework-jdk11-ci-image/Dockerfile deleted file mode 100644 index 6de48e0f1cbc..000000000000 --- a/ci/images/spring-framework-jdk11-ci-image/Dockerfile +++ /dev/null @@ -1,8 +0,0 @@ -FROM ubuntu:focal-20210119 - -ADD setup.sh /setup.sh -ADD get-jdk-url.sh /get-jdk-url.sh -RUN ./setup.sh java11 - -ENV JAVA_HOME /opt/openjdk -ENV PATH $JAVA_HOME/bin:$PATH diff --git a/ci/images/spring-framework-jdk15-ci-image/Dockerfile b/ci/images/spring-framework-jdk15-ci-image/Dockerfile deleted file mode 100644 index 71b47fbe07a7..000000000000 --- a/ci/images/spring-framework-jdk15-ci-image/Dockerfile +++ /dev/null @@ -1,8 +0,0 @@ -FROM ubuntu:focal-20210119 - -ADD setup.sh /setup.sh -ADD get-jdk-url.sh /get-jdk-url.sh -RUN ./setup.sh java15 - -ENV JAVA_HOME /opt/openjdk -ENV PATH $JAVA_HOME/bin:$PATH diff --git a/ci/parameters.yml b/ci/parameters.yml index 3e09f785ebf8..578a1b892998 100644 --- a/ci/parameters.yml +++ b/ci/parameters.yml @@ -6,9 +6,8 @@ github-repo-name: "spring-projects/spring-framework" docker-hub-organization: "springci" artifactory-server: "https://repo.spring.io" branch: "master" +milestone: "5.3.x" build-name: "spring-framework" pipeline-name: "spring-framework" concourse-url: "https://ci.spring.io" -bintray-subject: "spring" -bintray-repo: "jars" -task-timeout: 1h00m \ No newline at end of file +task-timeout: 1h00m diff --git a/ci/pipeline.yml b/ci/pipeline.yml index 7bfae68bd028..e77e3b3990ae 100644 --- a/ci/pipeline.yml +++ b/ci/pipeline.yml @@ -1,21 +1,32 @@ anchors: + git-repo-resource-source: &git-repo-resource-source + uri: ((github-repo)) + username: ((github-username)) + password: ((github-password)) + branch: ((branch)) + gradle-enterprise-task-params: &gradle-enterprise-task-params + GRADLE_ENTERPRISE_ACCESS_KEY: ((gradle_enterprise_secret_access_key)) + GRADLE_ENTERPRISE_CACHE_USERNAME: ((gradle_enterprise_cache_user.username)) + GRADLE_ENTERPRISE_CACHE_PASSWORD: ((gradle_enterprise_cache_user.password)) + sonatype-task-params: &sonatype-task-params + SONATYPE_USERNAME: ((sonatype-username)) + SONATYPE_PASSWORD: ((sonatype-password)) + SONATYPE_URL: ((sonatype-url)) + SONATYPE_STAGING_PROFILE_ID: ((sonatype-staging-profile-id)) artifactory-task-params: &artifactory-task-params ARTIFACTORY_SERVER: ((artifactory-server)) ARTIFACTORY_USERNAME: ((artifactory-username)) ARTIFACTORY_PASSWORD: ((artifactory-password)) - bintray-task-params: &bintray-task-params - BINTRAY_SUBJECT: ((bintray-subject)) - BINTRAY_REPO: ((bintray-repo)) - BINTRAY_USERNAME: ((bintray-username)) - BINTRAY_API_KEY: ((bintray-api-key)) + build-project-task-params: &build-project-task-params + privileged: true + timeout: ((task-timeout)) + params: + BRANCH: ((branch)) + <<: *gradle-enterprise-task-params docker-resource-source: &docker-resource-source username: ((docker-hub-username)) password: ((docker-hub-password)) - tag: 5.3.x - gradle-enterprise-task-params: &gradle-enterprise-task-params - GRADLE_ENTERPRISE_ACCESS_KEY: ((gradle_enterprise_secret_access_key)) - GRADLE_ENTERPRISE_CACHE_USERNAME: ((gradle_enterprise_cache_user.username)) - GRADLE_ENTERPRISE_CACHE_PASSWORD: ((gradle_enterprise_cache_user.password)) + tag: ((milestone)) slack-fail-params: &slack-fail-params text: > :concourse-failed: @@ -24,9 +35,6 @@ anchors: silent: true icon_emoji: ":concourse:" username: concourse-ci - sonatype-task-params: &sonatype-task-params - SONATYPE_USER_TOKEN: ((sonatype-user-token)) - SONATYPE_PASSWORD_TOKEN: ((sonatype-user-token-password)) changelog-task-params: &changelog-task-params name: generated-changelog/tag tag: generated-changelog/tag @@ -40,7 +48,7 @@ resource_types: type: registry-image source: repository: springio/artifactory-resource - tag: 0.0.12 + tag: 0.0.13 - name: github-status-resource type: registry-image source: @@ -51,16 +59,12 @@ resource_types: source: repository: cfcommunity/slack-notification-resource tag: latest - resources: - name: git-repo type: git icon: github source: - uri: ((github-repo)) - username: ((github-username)) - password: ((github-password)) - branch: ((branch)) + <<: *git-repo-resource-source - name: every-morning type: time icon: alarm @@ -75,24 +79,12 @@ resources: uri: ((github-repo)) branch: ((branch)) paths: ["ci/images/*"] -- name: spring-framework-ci-image - type: docker-image - icon: docker - source: - <<: *docker-resource-source - repository: ((docker-hub-organization))/spring-framework-ci-image -- name: spring-framework-jdk11-ci-image - type: docker-image - icon: docker - source: - <<: *docker-resource-source - repository: ((docker-hub-organization))/spring-framework-jdk11-ci-image -- name: spring-framework-jdk15-ci-image +- name: ci-image type: docker-image icon: docker source: <<: *docker-resource-source - repository: ((docker-hub-organization))/spring-framework-jdk15-ci-image + repository: ((docker-hub-organization))/spring-framework-ci - name: artifactory-repo type: artifactory-resource icon: package-variant @@ -147,43 +139,30 @@ resources: repository: spring-framework access_token: ((github-ci-release-token)) pre_release: false - jobs: -- name: build-spring-framework-ci-images +- name: build-ci-images plan: - get: ci-images-git-repo trigger: true - in_parallel: - - put: spring-framework-ci-image - params: - build: ci-images-git-repo/ci/images - dockerfile: ci-images-git-repo/ci/images/spring-framework-ci-image/Dockerfile - - put: spring-framework-jdk11-ci-image + - put: ci-image params: build: ci-images-git-repo/ci/images - dockerfile: ci-images-git-repo/ci/images/spring-framework-jdk11-ci-image/Dockerfile - - put: spring-framework-jdk15-ci-image - params: - build: ci-images-git-repo/ci/images - dockerfile: ci-images-git-repo/ci/images/spring-framework-jdk15-ci-image/Dockerfile + dockerfile: ci-images-git-repo/ci/images/ci-image/Dockerfile - name: build serial: true public: true plan: - - get: spring-framework-ci-image + - get: ci-image - get: git-repo trigger: true - put: repo-status-build params: { state: "pending", commit: "git-repo" } - do: - task: build-project - privileged: true - timeout: ((task-timeout)) - image: spring-framework-ci-image + image: ci-image file: git-repo/ci/tasks/build-project.yml - params: - BRANCH: ((branch)) - <<: *gradle-enterprise-task-params + <<: *build-project-task-params on_failure: do: - put: repo-status-build @@ -195,6 +174,8 @@ jobs: params: { state: "success", commit: "git-repo" } - put: artifactory-repo params: &artifactory-params + signing_key: ((signing-key)) + signing_passphrase: ((signing-passphrase)) repo: libs-snapshot-local folder: distribution-repository build_uri: "https://ci.spring.io/teams/${BUILD_TEAM_NAME}/pipelines/${BUILD_PIPELINE_NAME}/jobs/${BUILD_JOB_NAME}/builds/${BUILD_NAME}" @@ -226,7 +207,7 @@ jobs: serial: true public: true plan: - - get: spring-framework-jdk11-ci-image + - get: ci-image - get: git-repo - get: every-morning trigger: true @@ -234,13 +215,12 @@ jobs: params: { state: "pending", commit: "git-repo" } - do: - task: check-project - privileged: true - timeout: ((task-timeout)) - image: spring-framework-jdk11-ci-image + image: ci-image file: git-repo/ci/tasks/check-project.yml params: - BRANCH: ((branch)) - <<: *gradle-enterprise-task-params + MAIN_TOOLCHAIN: 8 + TEST_TOOLCHAIN: 11 + <<: *build-project-task-params on_failure: do: - put: repo-status-jdk11-build @@ -254,21 +234,20 @@ jobs: serial: true public: true plan: - - get: spring-framework-jdk15-ci-image + - get: ci-image - get: git-repo - get: every-morning trigger: true - put: repo-status-jdk15-build params: { state: "pending", commit: "git-repo" } - do: - - task: check-project - privileged: true - timeout: ((task-timeout)) - image: spring-framework-jdk15-ci-image - file: git-repo/ci/tasks/check-project.yml - params: - BRANCH: ((branch)) - <<: *gradle-enterprise-task-params + - task: check-project + image: ci-image + file: git-repo/ci/tasks/check-project.yml + params: + MAIN_TOOLCHAIN: 8 + TEST_TOOLCHAIN: 15 + <<: *build-project-task-params on_failure: do: - put: repo-status-jdk15-build @@ -281,11 +260,11 @@ jobs: - name: stage-milestone serial: true plan: - - get: spring-framework-ci-image + - get: ci-image - get: git-repo trigger: false - task: stage - image: spring-framework-ci-image + image: ci-image file: git-repo/ci/tasks/stage-version.yml params: RELEASE_TYPE: M @@ -300,7 +279,7 @@ jobs: - name: promote-milestone serial: true plan: - - get: spring-framework-ci-image + - get: ci-image - get: git-repo trigger: false - get: artifactory-repo @@ -310,7 +289,7 @@ jobs: download_artifacts: false save_build_info: true - task: promote - image: spring-framework-ci-image + image: ci-image file: git-repo/ci/tasks/promote-version.yml params: RELEASE_TYPE: M @@ -326,11 +305,11 @@ jobs: - name: stage-rc serial: true plan: - - get: spring-framework-ci-image + - get: ci-image - get: git-repo trigger: false - task: stage - image: spring-framework-ci-image + image: ci-image file: git-repo/ci/tasks/stage-version.yml params: RELEASE_TYPE: RC @@ -345,7 +324,7 @@ jobs: - name: promote-rc serial: true plan: - - get: spring-framework-ci-image + - get: ci-image - get: git-repo trigger: false - get: artifactory-repo @@ -355,7 +334,7 @@ jobs: download_artifacts: false save_build_info: true - task: promote - image: spring-framework-ci-image + image: ci-image file: git-repo/ci/tasks/promote-version.yml params: RELEASE_TYPE: RC @@ -371,11 +350,11 @@ jobs: - name: stage-release serial: true plan: - - get: spring-framework-ci-image + - get: ci-image - get: git-repo trigger: false - task: stage - image: spring-framework-ci-image + image: ci-image file: git-repo/ci/tasks/stage-version.yml params: RELEASE_TYPE: RELEASE @@ -390,26 +369,26 @@ jobs: - name: promote-release serial: true plan: - - get: spring-framework-ci-image + - get: ci-image - get: git-repo trigger: false - get: artifactory-repo trigger: false passed: [stage-release] params: - download_artifacts: false + download_artifacts: true save_build_info: true - task: promote - image: spring-framework-ci-image + image: ci-image file: git-repo/ci/tasks/promote-version.yml params: RELEASE_TYPE: RELEASE <<: *artifactory-task-params - <<: *bintray-task-params -- name: sync-to-maven-central + <<: *sonatype-task-params +- name: create-github-release serial: true plan: - - get: spring-framework-ci-image + - get: ci-image - get: git-repo - get: artifactory-repo trigger: true @@ -417,12 +396,6 @@ jobs: params: download_artifacts: false save_build_info: true - - task: sync-to-maven-central - image: spring-framework-ci-image - file: git-repo/ci/tasks/sync-to-maven-central.yml - params: - <<: *bintray-task-params - <<: *sonatype-task-params - task: generate-changelog file: git-repo/ci/tasks/generate-changelog.yml params: @@ -436,6 +409,6 @@ groups: - name: "builds" jobs: ["build", "jdk11-build", "jdk15-build"] - name: "releases" - jobs: ["stage-milestone", "stage-rc", "stage-release", "promote-milestone","promote-rc", "promote-release", "sync-to-maven-central"] + jobs: ["stage-milestone", "stage-rc", "stage-release", "promote-milestone", "promote-rc", "promote-release", "create-github-release"] - name: "ci-images" - jobs: ["build-spring-framework-ci-images"] + jobs: ["build-ci-images"] diff --git a/ci/scripts/check-project.sh b/ci/scripts/check-project.sh index 94c4e8df65b4..f2bf454e3597 100755 --- a/ci/scripts/check-project.sh +++ b/ci/scripts/check-project.sh @@ -4,5 +4,6 @@ set -e source $(dirname $0)/common.sh pushd git-repo > /dev/null -./gradlew -Dorg.gradle.internal.launcher.welcomeMessageEnabled=false --no-daemon --max-workers=4 check +./gradlew -Dorg.gradle.internal.launcher.welcomeMessageEnabled=false -Dorg.gradle.java.installations.fromEnv=JDK11,JDK15 \ + -PmainToolchain=$MAIN_TOOLCHAIN -PtestToolchain=$TEST_TOOLCHAIN --no-daemon --max-workers=4 check popd > /dev/null diff --git a/ci/scripts/generate-changelog.sh b/ci/scripts/generate-changelog.sh index b0bc952a33a4..d3d2b97e5dba 100755 --- a/ci/scripts/generate-changelog.sh +++ b/ci/scripts/generate-changelog.sh @@ -2,7 +2,7 @@ set -e CONFIG_DIR=git-repo/ci/config -version=$( cat version/version ) +version=$( cat artifactory-repo/build-info.json | jq -r '.buildInfo.modules[0].id' | sed 's/.*:.*:\(.*\)/\1/' ) java -jar /github-changelog-generator.jar \ --spring.config.location=${CONFIG_DIR}/changelog-generator.yml \ diff --git a/ci/scripts/promote-version.sh b/ci/scripts/promote-version.sh index 3b8dab0151db..44c5ff626f91 100755 --- a/ci/scripts/promote-version.sh +++ b/ci/scripts/promote-version.sh @@ -6,11 +6,13 @@ CONFIG_DIR=git-repo/ci/config version=$( cat artifactory-repo/build-info.json | jq -r '.buildInfo.modules[0].id' | sed 's/.*:.*:\(.*\)/\1/' ) export BUILD_INFO_LOCATION=$(pwd)/artifactory-repo/build-info.json -java -jar /opt/concourse-release-scripts.jar promote $RELEASE_TYPE $BUILD_INFO_LOCATION || { exit 1; } +java -jar /opt/concourse-release-scripts.jar \ + --spring.config.location=${CONFIG_DIR}/release-scripts.yml \ + publishToCentral $RELEASE_TYPE $BUILD_INFO_LOCATION artifactory-repo || { exit 1; } java -jar /opt/concourse-release-scripts.jar \ --spring.config.location=${CONFIG_DIR}/release-scripts.yml \ - distribute $RELEASE_TYPE $BUILD_INFO_LOCATION || { exit 1; } + promote $RELEASE_TYPE $BUILD_INFO_LOCATION || { exit 1; } echo "Promotion complete" echo $version > version/version diff --git a/ci/tasks/check-project.yml b/ci/tasks/check-project.yml index ea6d6ddb94c8..bea1185231b9 100644 --- a/ci/tasks/check-project.yml +++ b/ci/tasks/check-project.yml @@ -10,6 +10,8 @@ caches: params: BRANCH: CI: true + MAIN_TOOLCHAIN: + TEST_TOOLCHAIN: GRADLE_ENTERPRISE_ACCESS_KEY: GRADLE_ENTERPRISE_CACHE_USERNAME: GRADLE_ENTERPRISE_CACHE_PASSWORD: diff --git a/ci/tasks/generate-changelog.yml b/ci/tasks/generate-changelog.yml index 2df097bc2981..ea048af96a0f 100755 --- a/ci/tasks/generate-changelog.yml +++ b/ci/tasks/generate-changelog.yml @@ -1,13 +1,13 @@ --- platform: linux image_resource: - type: docker-image + type: registry-image source: repository: springio/github-changelog-generator - tag: '0.0.4' + tag: '0.0.6' inputs: - name: git-repo -- name: version +- name: artifactory-repo outputs: - name: generated-changelog params: diff --git a/ci/tasks/promote-version.yml b/ci/tasks/promote-version.yml index 2da899a0ebe0..abdd8fed5c5c 100644 --- a/ci/tasks/promote-version.yml +++ b/ci/tasks/promote-version.yml @@ -10,9 +10,9 @@ params: ARTIFACTORY_SERVER: ARTIFACTORY_USERNAME: ARTIFACTORY_PASSWORD: - BINTRAY_SUBJECT: - BINTRAY_REPO: - BINTRAY_USERNAME: - BINTRAY_API_KEY: + SONATYPE_USER: + SONATYPE_PASSWORD: + SONATYPE_URL: + SONATYPE_STAGING_PROFILE_ID: run: path: git-repo/ci/scripts/promote-version.sh diff --git a/ci/tasks/sync-to-maven-central.yml b/ci/tasks/sync-to-maven-central.yml deleted file mode 100644 index a44af5af1692..000000000000 --- a/ci/tasks/sync-to-maven-central.yml +++ /dev/null @@ -1,16 +0,0 @@ ---- -platform: linux -inputs: -- name: git-repo -- name: artifactory-repo -outputs: -- name: version -params: - BINTRAY_REPO: - BINTRAY_SUBJECT: - BINTRAY_USERNAME: - BINTRAY_API_KEY: - SONATYPE_USER_TOKEN: - SONATYPE_PASSWORD_TOKEN: -run: - path: git-repo/ci/scripts/sync-to-maven-central.sh diff --git a/gradle.properties b/gradle.properties index f1308025e55f..b23ad2ea5f08 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=5.3.4-SNAPSHOT +version=5.3.6 org.gradle.jvmargs=-Xmx1536M org.gradle.caching=true org.gradle.parallel=true diff --git a/gradle/custom-java-home.gradle b/gradle/custom-java-home.gradle deleted file mode 100644 index 54d1de1eb8f9..000000000000 --- a/gradle/custom-java-home.gradle +++ /dev/null @@ -1,80 +0,0 @@ -// ----------------------------------------------------------------------------- -// -// This script adds support for the following two JVM system properties -// that control the build for alternative JDKs (i.e., a JDK other than -// the one used to launch the Gradle process). -// -// - customJavaHome: absolute path to the alternate JDK installation to -// use to compile Java code and execute tests. This system property -// is also used in spring-oxm.gradle to determine whether JiBX is -// supported. -// -// - customJavaSourceVersion: Java version supplied to the `--release` -// command line flag to control the Java source and target -// compatibility version. Supported versions include 9 or higher. -// Do not set this system property if Java 8 should be used. -// -// Examples: -// -// ./gradlew -DcustomJavaHome=/Library/Java/JavaVirtualMachines/jdk-14.jdk/Contents/Home test -// -// ./gradlew --no-build-cache -DcustomJavaHome=/Library/Java/JavaVirtualMachines/jdk-14.jdk/Contents/Home test -// -// ./gradlew -DcustomJavaHome=/Library/Java/JavaVirtualMachines/jdk-14.jdk/Contents/Home -DcustomJavaSourceVersion=14 test -// -// -// Credits: inspired by work from Marc Philipp and Stephane Nicoll -// -// ----------------------------------------------------------------------------- - -import org.gradle.internal.os.OperatingSystem -// import org.jetbrains.kotlin.gradle.dsl.KotlinJvmCompile - -def customJavaHome = System.getProperty("customJavaHome") - -if (customJavaHome) { - def customJavaHomeDir = new File(customJavaHome) - def customJavaSourceVersion = System.getProperty("customJavaSourceVersion") - - tasks.withType(JavaCompile) { - logger.info("Java home for " + it.name + " task in " + project.name + ": " + customJavaHomeDir) - options.forkOptions.javaHome = customJavaHomeDir - inputs.property("customJavaHome", customJavaHome) - if (customJavaSourceVersion) { - options.compilerArgs += [ "--release", customJavaSourceVersion] - inputs.property("customJavaSourceVersion", customJavaSourceVersion) - } - } - - tasks.withType(GroovyCompile) { - logger.info("Java home for " + it.name + " task in " + project.name + ": " + customJavaHomeDir) - options.forkOptions.javaHome = customJavaHomeDir - inputs.property("customJavaHome", customJavaHome) - if (customJavaSourceVersion) { - options.compilerArgs += [ "--release", customJavaSourceVersion] - inputs.property("customJavaSourceVersion", customJavaSourceVersion) - } - } - - /* - tasks.withType(KotlinJvmCompile) { - logger.info("Java home for " + it.name + " task in " + project.name + ": " + customJavaHome) - kotlinOptions.jdkHome = customJavaHomeDir - inputs.property("customJavaHome", customJavaHome) - } - */ - - tasks.withType(Test) { - def javaExecutable = customJavaHome + "/bin/java" - if (OperatingSystem.current().isWindows()) { - javaExecutable += ".exe" - } - logger.info("Java executable for " + it.name + " task in " + project.name + ": " + javaExecutable) - executable = javaExecutable - inputs.property("customJavaHome", customJavaHome) - if (customJavaSourceVersion) { - inputs.property("customJavaSourceVersion", customJavaSourceVersion) - } - } - -} diff --git a/gradle/spring-module.gradle b/gradle/spring-module.gradle index 51b18007f832..49efaeae84dc 100644 --- a/gradle/spring-module.gradle +++ b/gradle/spring-module.gradle @@ -1,3 +1,4 @@ +apply plugin: 'java-library' apply plugin: 'org.springframework.build.compile' apply plugin: 'org.springframework.build.optional-dependencies' // Uncomment the following for Shadow support in the jmhJar block. diff --git a/gradle/toolchains.gradle b/gradle/toolchains.gradle new file mode 100644 index 000000000000..c6a61fe38414 --- /dev/null +++ b/gradle/toolchains.gradle @@ -0,0 +1,124 @@ +/** + * Apply the JVM Toolchain conventions + * See https://docs.gradle.org/current/userguide/toolchains.html + * + * One can choose the toolchain to use for compiling the MAIN sources and/or compiling + * and running the TEST sources. These options apply to Java, Kotlin and Groovy sources + * when available. + * {@code "./gradlew check -PmainToolchain=8 -PtestToolchain=11"} will use: + *

+ * + * Gradle will automatically detect JDK distributions in well-known locations. + * The following command will list the detected JDKs on the host. + * {@code + * $ ./gradlew -q javaToolchains + * } + * + * We can also configure ENV variables and let Gradle know about them: + * {@code + * $ echo JDK11 + * /opt/openjdk/java11 + * $ echo JDK15 + * /opt/openjdk/java15 + * $ ./gradlew -Dorg.gradle.java.installations.fromEnv=JDK11,JDK15 check + * } + * + * @author Brian Clozel + */ + +plugins.withType(JavaPlugin) { + // Configure the Java Toolchain if the 'mainToolchain' property is defined + if (project.hasProperty('mainToolchain') && project.mainToolchain) { + def mainLanguageVersion = JavaLanguageVersion.of(project.mainToolchain.toString()) + java { + toolchain { + languageVersion = mainLanguageVersion + } + } + } + else { + // Fallback to JDK8 + java { + sourceCompatibility = JavaVersion.VERSION_1_8 + } + } + // Configure a specific Java Toolchain for compiling and running tests if the 'testToolchain' property is defined + if (project.hasProperty('testToolchain') && project.testToolchain) { + def testLanguageVersion = JavaLanguageVersion.of(project.testToolchain.toString()); + tasks.withType(JavaCompile).matching { it.name.contains("Test") }.configureEach { + javaCompiler = javaToolchains.compilerFor { + languageVersion = testLanguageVersion + } + } + tasks.withType(Test).configureEach{ + javaLauncher = javaToolchains.launcherFor { + languageVersion = testLanguageVersion + } + } + } +} + +plugins.withType(GroovyPlugin) { + // Fallback to JDK8 + if (!project.hasProperty('mainToolchain')) { + compileGroovy { + sourceCompatibility = JavaVersion.VERSION_1_8 + } + } +} + +// Configure the Kotlin compiler if the 'mainToolchain' property is defined +pluginManager.withPlugin("kotlin") { + if (project.hasProperty('mainToolchain') && project.mainToolchain) { + def mainLanguageVersion = JavaLanguageVersion.of(project.mainToolchain.toString()); + def compiler = javaToolchains.compilerFor { + languageVersion = mainLanguageVersion + } + // See https://kotlinlang.org/docs/gradle.html#attributes-specific-for-jvm + def javaVersion = mainLanguageVersion.toString() == '8' ? '1.8' : mainLanguageVersion.toString() + compileKotlin { + kotlinOptions { + jvmTarget = javaVersion + jdkHome = compiler.get().metadata.installationPath.asFile.absolutePath + } + } + // Compile the test classes with the same version, 'testToolchain' will override if defined + compileTestKotlin { + kotlinOptions { + jvmTarget = javaVersion + jdkHome = compiler.get().metadata.installationPath.asFile.absolutePath + } + } + } + else { + // Fallback to JDK8 + compileKotlin { + kotlinOptions { + jvmTarget = '1.8' + } + } + compileTestKotlin { + kotlinOptions { + jvmTarget = '1.8' + } + } + } + + if (project.hasProperty('testToolchain') && project.testToolchain) { + def testLanguageVersion = JavaLanguageVersion.of(project.testToolchain.toString()); + def compiler = javaToolchains.compilerFor { + languageVersion = testLanguageVersion + } + // See https://kotlinlang.org/docs/gradle.html#attributes-specific-for-jvm + def javaVersion = testLanguageVersion.toString() == '8' ? '1.8' : testLanguageVersion.toString() + compileTestKotlin { + kotlinOptions { + jvmTarget = javaVersion + jdkHome = compiler.get().metadata.installationPath.asFile.absolutePath + } + } + } +} \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 2a563242c113..442d9132ea32 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.3-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/settings.gradle b/settings.gradle index be0672db505e..8c1472350f11 100644 --- a/settings.gradle +++ b/settings.gradle @@ -6,7 +6,7 @@ pluginManagement { } plugins { - id "com.gradle.enterprise" version "3.5.1" + id "com.gradle.enterprise" version "3.6.1" id "io.spring.ge.conventions" version "0.0.7" } @@ -45,11 +45,11 @@ rootProject.children.each {project -> settings.gradle.projectsLoaded { gradleEnterprise { buildScan { - if (settings.gradle.rootProject.hasProperty('customJavaHome')) { - value("Custom JAVA_HOME", settings.gradle.rootProject.getProperty('customJavaHome')) + if (settings.gradle.rootProject.hasProperty('mainToolchain')) { + value("Main toolchain", 'JDK' + settings.gradle.rootProject.getProperty('mainToolchain')) } - if (settings.gradle.rootProject.hasProperty('customJavaSourceVersion')) { - value("Custom Java Source Version", settings.gradle.rootProject.getProperty('customJavaSourceVersion')) + if (settings.gradle.rootProject.hasProperty('testToolchain')) { + value("Test toolchain", 'JDK' + settings.gradle.rootProject.getProperty('testToolchain')) } File buildDir = settings.gradle.rootProject.getBuildDir() buildDir.mkdirs() diff --git a/spring-aop/src/main/java/org/springframework/aop/DynamicIntroductionAdvice.java b/spring-aop/src/main/java/org/springframework/aop/DynamicIntroductionAdvice.java index 08c704857f75..2f46775b9459 100644 --- a/spring-aop/src/main/java/org/springframework/aop/DynamicIntroductionAdvice.java +++ b/spring-aop/src/main/java/org/springframework/aop/DynamicIntroductionAdvice.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2012 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,7 +26,7 @@ *

Introductions are often mixins, enabling the building of composite * objects that can achieve many of the goals of multiple inheritance in Java. * - *

Compared to {qlink IntroductionInfo}, this interface allows an advice to + *

Compared to {@link IntroductionInfo}, this interface allows an advice to * implement a range of interfaces that is not necessarily known in advance. * Thus an {@link IntroductionAdvisor} can be used to specify which interfaces * will be exposed in an advised object. diff --git a/spring-aop/src/main/java/org/springframework/aop/framework/AbstractAdvisingBeanPostProcessor.java b/spring-aop/src/main/java/org/springframework/aop/framework/AbstractAdvisingBeanPostProcessor.java index a3a87f2117f8..d941bdac4c9c 100644 --- a/spring-aop/src/main/java/org/springframework/aop/framework/AbstractAdvisingBeanPostProcessor.java +++ b/spring-aop/src/main/java/org/springframework/aop/framework/AbstractAdvisingBeanPostProcessor.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,6 +22,7 @@ import org.springframework.aop.Advisor; import org.springframework.aop.support.AopUtils; import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.core.SmartClassLoader; import org.springframework.lang.Nullable; /** @@ -89,7 +90,13 @@ public Object postProcessAfterInitialization(Object bean, String beanName) { } proxyFactory.addAdvisor(this.advisor); customizeProxyFactory(proxyFactory); - return proxyFactory.getProxy(getProxyClassLoader()); + + // Use original ClassLoader if bean class not locally loaded in overriding class loader + ClassLoader classLoader = getProxyClassLoader(); + if (classLoader instanceof SmartClassLoader && classLoader != bean.getClass().getClassLoader()) { + classLoader = ((SmartClassLoader) classLoader).getOriginalClassLoader(); + } + return proxyFactory.getProxy(classLoader); } // No proxy needed. diff --git a/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/AbstractAutoProxyCreator.java b/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/AbstractAutoProxyCreator.java index d4ffb04faf15..79c1fe20ca33 100644 --- a/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/AbstractAutoProxyCreator.java +++ b/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/AbstractAutoProxyCreator.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -46,6 +46,7 @@ import org.springframework.beans.factory.config.ConfigurableBeanFactory; import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; import org.springframework.beans.factory.config.SmartInstantiationAwareBeanPostProcessor; +import org.springframework.core.SmartClassLoader; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -458,7 +459,12 @@ protected Object createProxy(Class beanClass, @Nullable String beanName, proxyFactory.setPreFiltered(true); } - return proxyFactory.getProxy(getProxyClassLoader()); + // Use original ClassLoader if bean class not locally loaded in overriding class loader + ClassLoader classLoader = getProxyClassLoader(); + if (classLoader instanceof SmartClassLoader && classLoader != beanClass.getClassLoader()) { + classLoader = ((SmartClassLoader) classLoader).getOriginalClassLoader(); + } + return proxyFactory.getProxy(classLoader); } /** @@ -503,7 +509,10 @@ protected Advisor[] buildAdvisors(@Nullable String beanName, @Nullable Object[] List allInterceptors = new ArrayList<>(); if (specificInterceptors != null) { - allInterceptors.addAll(Arrays.asList(specificInterceptors)); + if (specificInterceptors.length > 0) { + // specificInterceptors may equals PROXY_WITHOUT_ADDITIONAL_INTERCEPTORS + allInterceptors.addAll(Arrays.asList(specificInterceptors)); + } if (commonInterceptors.length > 0) { if (this.applyCommonInterceptorsFirst) { allInterceptors.addAll(0, Arrays.asList(commonInterceptors)); diff --git a/spring-beans/spring-beans.gradle b/spring-beans/spring-beans.gradle index 73e9942f8ef6..db894e8b83bc 100644 --- a/spring-beans/spring-beans.gradle +++ b/spring-beans/spring-beans.gradle @@ -23,8 +23,6 @@ sourceSets { } compileGroovy { - sourceCompatibility = 1.8 - targetCompatibility = 1.8 options.compilerArgs += "-Werror" } diff --git a/spring-beans/src/main/java/org/springframework/beans/AbstractNestablePropertyAccessor.java b/spring-beans/src/main/java/org/springframework/beans/AbstractNestablePropertyAccessor.java index 16ab258a14e9..b56ec6b9533b 100644 --- a/spring-beans/src/main/java/org/springframework/beans/AbstractNestablePropertyAccessor.java +++ b/spring-beans/src/main/java/org/springframework/beans/AbstractNestablePropertyAccessor.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -305,8 +305,10 @@ private void processKeyedProperty(PropertyTokenHolder tokens, PropertyValue pv) Class componentType = propValue.getClass().getComponentType(); Object newArray = Array.newInstance(componentType, arrayIndex + 1); System.arraycopy(propValue, 0, newArray, 0, length); - setPropertyValue(tokens.actualName, newArray); - propValue = getPropertyValue(tokens.actualName); + int lastKeyIndex = tokens.canonicalName.lastIndexOf('['); + String propName = tokens.canonicalName.substring(0, lastKeyIndex); + setPropertyValue(propName, newArray); + propValue = getPropertyValue(propName); } Array.set(propValue, arrayIndex, convertedValue); } diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/config/PlaceholderConfigurerSupport.java b/spring-beans/src/main/java/org/springframework/beans/factory/config/PlaceholderConfigurerSupport.java index fc29a9eda995..9a11f7af3ff2 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/config/PlaceholderConfigurerSupport.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/config/PlaceholderConfigurerSupport.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -36,15 +36,16 @@ * Example XML bean definition: * *
- * <bean id="dataSource" class="org.springframework.jdbc.datasource.DriverManagerDataSource"/>
- *   <property name="driverClassName" value="${driver}"/>
- *   <property name="url" value="jdbc:${dbname}"/>
+ * <bean id="dataSource" class="org.springframework.jdbc.datasource.DriverManagerDataSource">
+ *   <property name="driverClassName" value="${driver}" />
+ *   <property name="url" value="jdbc:${dbname}" />
  * </bean>
  * 
* * Example properties file: * - *
driver=com.mysql.jdbc.Driver
+ * 
+ * driver=com.mysql.jdbc.Driver
  * dbname=mysql:mydb
* * Annotated bean definitions may take advantage of property replacement using @@ -56,7 +57,8 @@ * in bean references. Furthermore, placeholder values can also cross-reference * other placeholders, like: * - *
rootPath=myrootdir
+ * 
+ * rootPath=myrootdir
  * subPath=${rootPath}/subdir
* * In contrast to {@link PropertyOverrideConfigurer}, subclasses of this type allow @@ -71,13 +73,13 @@ * *

Default property values can be defined globally for each configurer instance * via the {@link #setProperties properties} property, or on a property-by-property basis - * using the default value separator which is {@code ":"} by default and - * customizable via {@link #setValueSeparator(String)}. + * using the value separator which is {@code ":"} by default and customizable via + * {@link #setValueSeparator(String)}. * *

Example XML property with default value: * *

- *   
+ *   <property name="url" value="jdbc:${dbname:defaultdb}" />
  * 
* * @author Chris Beams diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/groovy/GroovyBeanDefinitionReader.java b/spring-beans/src/main/java/org/springframework/beans/factory/groovy/GroovyBeanDefinitionReader.java index cf13a4081730..14427fee93ec 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/groovy/GroovyBeanDefinitionReader.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/groovy/GroovyBeanDefinitionReader.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -489,8 +489,8 @@ else if (args[0] instanceof Map) { resolveConstructorArguments(args, 2, hasClosureArgument ? args.length - 1 : args.length); this.currentBeanDefinition = new GroovyBeanDefinitionWrapper(beanName, (Class) args[1], constructorArgs); Map namedArgs = (Map) args[0]; - for (Object o : namedArgs.keySet()) { - String propName = (String) o; + for (Object key : namedArgs.keySet()) { + String propName = (String) key; setProperty(propName, namedArgs.get(propName)); } } diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/support/AbstractBeanDefinition.java b/spring-beans/src/main/java/org/springframework/beans/factory/support/AbstractBeanDefinition.java index d4b0aef01522..5b5471a88323 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/support/AbstractBeanDefinition.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/support/AbstractBeanDefinition.java @@ -894,7 +894,7 @@ public MutablePropertyValues getPropertyValues() { } /** - * Return if there are property values values defined for this bean. + * Return if there are property values defined for this bean. * @since 5.0.2 */ @Override diff --git a/spring-beans/src/main/java/org/springframework/beans/propertyeditors/PathEditor.java b/spring-beans/src/main/java/org/springframework/beans/propertyeditors/PathEditor.java index f1edae00c793..0ee0f5e80f60 100644 --- a/spring-beans/src/main/java/org/springframework/beans/propertyeditors/PathEditor.java +++ b/spring-beans/src/main/java/org/springframework/beans/propertyeditors/PathEditor.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,8 +26,8 @@ import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceEditor; -import org.springframework.core.io.ResourceLoader; import org.springframework.util.Assert; +import org.springframework.util.ResourceUtils; /** * Editor for {@code java.nio.file.Path}, to directly populate a Path @@ -74,7 +74,7 @@ public PathEditor(ResourceEditor resourceEditor) { @Override public void setAsText(String text) throws IllegalArgumentException { - boolean nioPathCandidate = !text.startsWith(ResourceLoader.CLASSPATH_URL_PREFIX); + boolean nioPathCandidate = !text.startsWith(ResourceUtils.CLASSPATH_URL_PREFIX); if (nioPathCandidate && !text.startsWith("/")) { try { URI uri = new URI(text); @@ -85,9 +85,13 @@ public void setAsText(String text) throws IllegalArgumentException { return; } } - catch (URISyntaxException | FileSystemNotFoundException ex) { - // Not a valid URI (let's try as Spring resource location), - // or a URI scheme not registered for NIO (let's try URL + catch (URISyntaxException ex) { + // Not a valid URI; potentially a Windows-style path after + // a file prefix (let's try as Spring resource location) + nioPathCandidate = !text.startsWith(ResourceUtils.FILE_URL_PREFIX); + } + catch (FileSystemNotFoundException ex) { + // URI scheme not registered for NIO (let's try URL // protocol handlers via Spring's resource mechanism). } } @@ -97,7 +101,7 @@ public void setAsText(String text) throws IllegalArgumentException { if (resource == null) { setValue(null); } - else if (!resource.exists() && nioPathCandidate) { + else if (nioPathCandidate && !resource.exists()) { setValue(Paths.get(text).normalize()); } else { diff --git a/spring-beans/src/test/java/org/springframework/beans/BeanWrapperAutoGrowingTests.java b/spring-beans/src/test/java/org/springframework/beans/BeanWrapperAutoGrowingTests.java index c17e2c7359b0..2dddf884e319 100644 --- a/spring-beans/src/test/java/org/springframework/beans/BeanWrapperAutoGrowingTests.java +++ b/spring-beans/src/test/java/org/springframework/beans/BeanWrapperAutoGrowingTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,6 +28,7 @@ /** * @author Keith Donald * @author Juergen Hoeller + * @author Sam Brannen */ public class BeanWrapperAutoGrowingTests { @@ -66,11 +67,6 @@ public void getPropertyValueAutoGrowArray() { assertThat(bean.getArray()[0]).isInstanceOf(Bean.class); } - private void assertNotNull(Object propertyValue) { - assertThat(propertyValue).isNotNull(); - } - - @Test public void setPropertyValueAutoGrowArray() { wrapper.setPropertyValue("array[0].prop", "test"); @@ -93,12 +89,39 @@ public void getPropertyValueAutoGrowArrayBySeveralElements() { } @Test - public void getPropertyValueAutoGrowMultiDimensionalArray() { + public void getPropertyValueAutoGrow2dArray() { assertNotNull(wrapper.getPropertyValue("multiArray[0][0]")); assertThat(bean.getMultiArray()[0].length).isEqualTo(1); assertThat(bean.getMultiArray()[0][0]).isInstanceOf(Bean.class); } + @Test + public void getPropertyValueAutoGrow3dArray() { + assertNotNull(wrapper.getPropertyValue("threeDimensionalArray[1][2][3]")); + assertThat(bean.getThreeDimensionalArray()[1].length).isEqualTo(3); + assertThat(bean.getThreeDimensionalArray()[1][2][3]).isInstanceOf(Bean.class); + } + + @Test + public void setPropertyValueAutoGrow2dArray() { + Bean newBean = new Bean(); + newBean.setProp("enigma"); + wrapper.setPropertyValue("multiArray[2][3]", newBean); + assertThat(bean.getMultiArray()[2][3]) + .isInstanceOf(Bean.class) + .extracting(Bean::getProp).isEqualTo("enigma"); + } + + @Test + public void setPropertyValueAutoGrow3dArray() { + Bean newBean = new Bean(); + newBean.setProp("enigma"); + wrapper.setPropertyValue("threeDimensionalArray[2][3][4]", newBean); + assertThat(bean.getThreeDimensionalArray()[2][3][4]) + .isInstanceOf(Bean.class) + .extracting(Bean::getProp).isEqualTo("enigma"); + } + @Test public void getPropertyValueAutoGrowList() { assertNotNull(wrapper.getPropertyValue("list[0]")); @@ -131,7 +154,7 @@ public void getPropertyValueAutoGrowListBySeveralElements() { public void getPropertyValueAutoGrowListFailsAgainstLimit() { wrapper.setAutoGrowCollectionLimit(2); assertThatExceptionOfType(InvalidPropertyException.class).isThrownBy(() -> - assertNotNull(wrapper.getPropertyValue("list[4]"))) + wrapper.getPropertyValue("list[4]")) .withRootCauseInstanceOf(IndexOutOfBoundsException.class); } @@ -161,6 +184,11 @@ public void setNestedPropertyValueAutoGrowMap() { } + private static void assertNotNull(Object propertyValue) { + assertThat(propertyValue).isNotNull(); + } + + @SuppressWarnings("rawtypes") public static class Bean { @@ -174,6 +202,8 @@ public static class Bean { private Bean[][] multiArray; + private Bean[][][] threeDimensionalArray; + private List list; private List> multiList; @@ -214,6 +244,14 @@ public void setMultiArray(Bean[][] multiArray) { this.multiArray = multiArray; } + public Bean[][][] getThreeDimensionalArray() { + return threeDimensionalArray; + } + + public void setThreeDimensionalArray(Bean[][][] threeDimensionalArray) { + this.threeDimensionalArray = threeDimensionalArray; + } + public List getList() { return list; } diff --git a/spring-beans/src/test/java/org/springframework/beans/propertyeditors/PathEditorTests.java b/spring-beans/src/test/java/org/springframework/beans/propertyeditors/PathEditorTests.java index 40354fc44643..f0c659bcbdb7 100644 --- a/spring-beans/src/test/java/org/springframework/beans/propertyeditors/PathEditorTests.java +++ b/spring-beans/src/test/java/org/springframework/beans/propertyeditors/PathEditorTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -34,57 +34,78 @@ public class PathEditorTests { @Test - public void testClasspathPathName() throws Exception { + public void testClasspathPathName() { PropertyEditor pathEditor = new PathEditor(); pathEditor.setAsText("classpath:" + ClassUtils.classPackageAsResourcePath(getClass()) + "/" + ClassUtils.getShortName(getClass()) + ".class"); Object value = pathEditor.getValue(); - boolean condition = value instanceof Path; - assertThat(condition).isTrue(); + assertThat(value instanceof Path).isTrue(); Path path = (Path) value; assertThat(path.toFile().exists()).isTrue(); } @Test - public void testWithNonExistentResource() throws Exception { + public void testWithNonExistentResource() { PropertyEditor propertyEditor = new PathEditor(); assertThatIllegalArgumentException().isThrownBy(() -> propertyEditor.setAsText("classpath:/no_way_this_file_is_found.doc")); } @Test - public void testWithNonExistentPath() throws Exception { + public void testWithNonExistentPath() { PropertyEditor pathEditor = new PathEditor(); pathEditor.setAsText("file:/no_way_this_file_is_found.doc"); Object value = pathEditor.getValue(); - boolean condition1 = value instanceof Path; - assertThat(condition1).isTrue(); + assertThat(value instanceof Path).isTrue(); Path path = (Path) value; - boolean condition = !path.toFile().exists(); - assertThat(condition).isTrue(); + assertThat(!path.toFile().exists()).isTrue(); } @Test - public void testAbsolutePath() throws Exception { + public void testAbsolutePath() { PropertyEditor pathEditor = new PathEditor(); pathEditor.setAsText("/no_way_this_file_is_found.doc"); Object value = pathEditor.getValue(); - boolean condition1 = value instanceof Path; - assertThat(condition1).isTrue(); + assertThat(value instanceof Path).isTrue(); Path path = (Path) value; - boolean condition = !path.toFile().exists(); - assertThat(condition).isTrue(); + assertThat(!path.toFile().exists()).isTrue(); } @Test - public void testUnqualifiedPathNameFound() throws Exception { + public void testWindowsAbsolutePath() { + PropertyEditor pathEditor = new PathEditor(); + pathEditor.setAsText("C:\\no_way_this_file_is_found.doc"); + Object value = pathEditor.getValue(); + assertThat(value instanceof Path).isTrue(); + Path path = (Path) value; + assertThat(!path.toFile().exists()).isTrue(); + } + + @Test + public void testWindowsAbsoluteFilePath() { + PropertyEditor pathEditor = new PathEditor(); + try { + pathEditor.setAsText("file://C:\\no_way_this_file_is_found.doc"); + Object value = pathEditor.getValue(); + assertThat(value instanceof Path).isTrue(); + Path path = (Path) value; + assertThat(!path.toFile().exists()).isTrue(); + } + catch (IllegalArgumentException ex) { + if (File.separatorChar == '\\') { // on Windows, otherwise silently ignore + throw ex; + } + } + } + + @Test + public void testUnqualifiedPathNameFound() { PropertyEditor pathEditor = new PathEditor(); String fileName = ClassUtils.classPackageAsResourcePath(getClass()) + "/" + ClassUtils.getShortName(getClass()) + ".class"; pathEditor.setAsText(fileName); Object value = pathEditor.getValue(); - boolean condition = value instanceof Path; - assertThat(condition).isTrue(); + assertThat(value instanceof Path).isTrue(); Path path = (Path) value; File file = path.toFile(); assertThat(file.exists()).isTrue(); @@ -96,14 +117,13 @@ public void testUnqualifiedPathNameFound() throws Exception { } @Test - public void testUnqualifiedPathNameNotFound() throws Exception { + public void testUnqualifiedPathNameNotFound() { PropertyEditor pathEditor = new PathEditor(); String fileName = ClassUtils.classPackageAsResourcePath(getClass()) + "/" + ClassUtils.getShortName(getClass()) + ".clazz"; pathEditor.setAsText(fileName); Object value = pathEditor.getValue(); - boolean condition = value instanceof Path; - assertThat(condition).isTrue(); + assertThat(value instanceof Path).isTrue(); Path path = (Path) value; File file = path.toFile(); assertThat(file.exists()).isFalse(); diff --git a/spring-context/src/main/java/org/springframework/context/ApplicationListener.java b/spring-context/src/main/java/org/springframework/context/ApplicationListener.java index 3194f52091a4..cb0891b0c77a 100644 --- a/spring-context/src/main/java/org/springframework/context/ApplicationListener.java +++ b/spring-context/src/main/java/org/springframework/context/ApplicationListener.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -35,6 +35,8 @@ * @param the specific {@code ApplicationEvent} subclass to listen to * @see org.springframework.context.ApplicationEvent * @see org.springframework.context.event.ApplicationEventMulticaster + * @see org.springframework.context.event.SmartApplicationListener + * @see org.springframework.context.event.GenericApplicationListener * @see org.springframework.context.event.EventListener */ @FunctionalInterface diff --git a/spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassPostProcessor.java b/spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassPostProcessor.java index 326a5bab3a62..7913e61f56b2 100644 --- a/spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassPostProcessor.java +++ b/spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassPostProcessor.java @@ -75,16 +75,17 @@ * *

Registered by default when using {@code } or * {@code }. Otherwise, may be declared manually as - * with any other BeanFactoryPostProcessor. + * with any other {@link BeanFactoryPostProcessor}. * *

This post processor is priority-ordered as it is important that any - * {@link Bean} methods declared in {@code @Configuration} classes have + * {@link Bean @Bean} methods declared in {@code @Configuration} classes have * their corresponding bean definitions registered before any other - * {@link BeanFactoryPostProcessor} executes. + * {@code BeanFactoryPostProcessor} executes. * * @author Chris Beams * @author Juergen Hoeller * @author Phillip Webb + * @author Sam Brannen * @since 3.0 */ public class ConfigurationClassPostProcessor implements BeanDefinitionRegistryPostProcessor, @@ -389,21 +390,30 @@ public void enhanceConfigurationClasses(ConfigurableListableBeanFactory beanFact for (String beanName : beanFactory.getBeanDefinitionNames()) { BeanDefinition beanDef = beanFactory.getBeanDefinition(beanName); Object configClassAttr = beanDef.getAttribute(ConfigurationClassUtils.CONFIGURATION_CLASS_ATTRIBUTE); + AnnotationMetadata annotationMetadata = null; MethodMetadata methodMetadata = null; if (beanDef instanceof AnnotatedBeanDefinition) { - methodMetadata = ((AnnotatedBeanDefinition) beanDef).getFactoryMethodMetadata(); + AnnotatedBeanDefinition annotatedBeanDefinition = (AnnotatedBeanDefinition) beanDef; + annotationMetadata = annotatedBeanDefinition.getMetadata(); + methodMetadata = annotatedBeanDefinition.getFactoryMethodMetadata(); } if ((configClassAttr != null || methodMetadata != null) && beanDef instanceof AbstractBeanDefinition) { // Configuration class (full or lite) or a configuration-derived @Bean method - // -> resolve bean class at this point... + // -> eagerly resolve bean class at this point, unless it's a 'lite' configuration + // or component class without @Bean methods. AbstractBeanDefinition abd = (AbstractBeanDefinition) beanDef; if (!abd.hasBeanClass()) { - try { - abd.resolveBeanClass(this.beanClassLoader); - } - catch (Throwable ex) { - throw new IllegalStateException( - "Cannot load configuration class: " + beanDef.getBeanClassName(), ex); + boolean liteConfigurationCandidateWithoutBeanMethods = + (ConfigurationClassUtils.CONFIGURATION_CLASS_LITE.equals(configClassAttr) && + annotationMetadata != null && !ConfigurationClassUtils.hasBeanMethods(annotationMetadata)); + if (!liteConfigurationCandidateWithoutBeanMethods) { + try { + abd.resolveBeanClass(this.beanClassLoader); + } + catch (Throwable ex) { + throw new IllegalStateException( + "Cannot load configuration class: " + beanDef.getBeanClassName(), ex); + } } } } diff --git a/spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassUtils.java b/spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassUtils.java index 3758084c3197..da377b13fd30 100644 --- a/spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassUtils.java +++ b/spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -46,6 +46,7 @@ * * @author Chris Beams * @author Juergen Hoeller + * @author Sam Brannen * @since 3.1 */ abstract class ConfigurationClassUtils { @@ -162,6 +163,10 @@ public static boolean isConfigurationCandidate(AnnotationMetadata metadata) { } // Finally, let's look for @Bean methods... + return hasBeanMethods(metadata); + } + + static boolean hasBeanMethods(AnnotationMetadata metadata) { try { return metadata.hasAnnotatedMethods(Bean.class.getName()); } diff --git a/spring-context/src/main/java/org/springframework/context/event/AbstractApplicationEventMulticaster.java b/spring-context/src/main/java/org/springframework/context/event/AbstractApplicationEventMulticaster.java index 9f0fd1d25be1..d765539d0ada 100644 --- a/spring-context/src/main/java/org/springframework/context/event/AbstractApplicationEventMulticaster.java +++ b/spring-context/src/main/java/org/springframework/context/event/AbstractApplicationEventMulticaster.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,6 +23,7 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Predicate; import org.springframework.aop.framework.AopProxyUtils; import org.springframework.beans.factory.BeanClassLoaderAware; @@ -137,6 +138,22 @@ public void removeApplicationListenerBean(String listenerBeanName) { } } + @Override + public void removeApplicationListeners(Predicate> predicate) { + synchronized (this.defaultRetriever) { + this.defaultRetriever.applicationListeners.removeIf(predicate); + this.retrieverCache.clear(); + } + } + + @Override + public void removeApplicationListenerBeans(Predicate predicate) { + synchronized (this.defaultRetriever) { + this.defaultRetriever.applicationListenerBeans.removeIf(predicate); + this.retrieverCache.clear(); + } + } + @Override public void removeAllListeners() { synchronized (this.defaultRetriever) { diff --git a/spring-context/src/main/java/org/springframework/context/event/ApplicationEventMulticaster.java b/spring-context/src/main/java/org/springframework/context/event/ApplicationEventMulticaster.java index 5810d8bcb3b6..4fff846e8c96 100644 --- a/spring-context/src/main/java/org/springframework/context/event/ApplicationEventMulticaster.java +++ b/spring-context/src/main/java/org/springframework/context/event/ApplicationEventMulticaster.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,8 @@ package org.springframework.context.event; +import java.util.function.Predicate; + import org.springframework.context.ApplicationEvent; import org.springframework.context.ApplicationListener; import org.springframework.core.ResolvableType; @@ -39,31 +41,68 @@ public interface ApplicationEventMulticaster { /** * Add a listener to be notified of all events. * @param listener the listener to add + * @see #removeApplicationListener(ApplicationListener) + * @see #removeApplicationListeners(Predicate) */ void addApplicationListener(ApplicationListener listener); /** * Add a listener bean to be notified of all events. * @param listenerBeanName the name of the listener bean to add + * @see #removeApplicationListenerBean(String) + * @see #removeApplicationListenerBeans(Predicate) */ void addApplicationListenerBean(String listenerBeanName); /** * Remove a listener from the notification list. * @param listener the listener to remove + * @see #addApplicationListener(ApplicationListener) + * @see #removeApplicationListeners(Predicate) */ void removeApplicationListener(ApplicationListener listener); /** * Remove a listener bean from the notification list. * @param listenerBeanName the name of the listener bean to remove + * @see #addApplicationListenerBean(String) + * @see #removeApplicationListenerBeans(Predicate) */ void removeApplicationListenerBean(String listenerBeanName); + /** + * Remove all matching listeners from the set of registered + * {@code ApplicationListener} instances (which includes adapter classes + * such as {@link ApplicationListenerMethodAdapter}, e.g. for annotated + * {@link EventListener} methods). + *

Note: This just applies to instance registrations, not to listeners + * registered by bean name. + * @param predicate the predicate to identify listener instances to remove, + * e.g. checking {@link SmartApplicationListener#getListenerId()} + * @since 5.3.5 + * @see #addApplicationListener(ApplicationListener) + * @see #removeApplicationListener(ApplicationListener) + */ + void removeApplicationListeners(Predicate> predicate); + + /** + * Remove all matching listener beans from the set of registered + * listener bean names (referring to bean classes which in turn + * implement the {@link ApplicationListener} interface directly). + *

Note: This just applies to bean name registrations, not to + * programmatically registered {@code ApplicationListener} instances. + * @param predicate the predicate to identify listener bean names to remove + * @since 5.3.5 + * @see #addApplicationListenerBean(String) + * @see #removeApplicationListenerBean(String) + */ + void removeApplicationListenerBeans(Predicate predicate); + /** * Remove all listeners registered with this multicaster. *

After a remove call, the multicaster will perform no action * on event notification until new listeners are registered. + * @see #removeApplicationListeners(Predicate) */ void removeAllListeners(); diff --git a/spring-context/src/main/java/org/springframework/context/event/ApplicationListenerMethodAdapter.java b/spring-context/src/main/java/org/springframework/context/event/ApplicationListenerMethodAdapter.java index 02f4dc8093f3..e78c794ca555 100644 --- a/spring-context/src/main/java/org/springframework/context/event/ApplicationListenerMethodAdapter.java +++ b/spring-context/src/main/java/org/springframework/context/event/ApplicationListenerMethodAdapter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,6 +24,7 @@ import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.StringJoiner; import java.util.concurrent.CompletionStage; import org.apache.commons.logging.Log; @@ -89,6 +90,9 @@ public class ApplicationListenerMethodAdapter implements GenericApplicationListe private final int order; + @Nullable + private volatile String listenerId; + @Nullable private ApplicationContext applicationContext; @@ -113,6 +117,8 @@ public ApplicationListenerMethodAdapter(String beanName, Class targetClass, M this.declaredEventTypes = resolveDeclaredEventTypes(method, ann); this.condition = (ann != null ? ann.condition() : null); this.order = resolveOrder(this.targetMethod); + String id = (ann != null ? ann.id() : ""); + this.listenerId = (!id.isEmpty() ? id : null); } private static List resolveDeclaredEventTypes(Method method, @Nullable EventListener ann) { @@ -186,6 +192,32 @@ public int getOrder() { return this.order; } + @Override + public String getListenerId() { + String id = this.listenerId; + if (id == null) { + id = getDefaultListenerId(); + this.listenerId = id; + } + return id; + } + + /** + * Determine the default id for the target listener, to be applied in case of + * no {@link EventListener#id() annotation-specified id value}. + *

The default implementation builds a method name with parameter types. + * @since 5.3.5 + * @see #getListenerId() + */ + protected String getDefaultListenerId() { + Method method = getTargetMethod(); + StringJoiner sj = new StringJoiner(",", "(", ")"); + for (Class paramType : method.getParameterTypes()) { + sj.add(paramType.getName()); + } + return ClassUtils.getQualifiedMethodName(method) + sj.toString(); + } + /** * Process the specified {@link ApplicationEvent}, checking if the condition diff --git a/spring-context/src/main/java/org/springframework/context/event/EventListener.java b/spring-context/src/main/java/org/springframework/context/event/EventListener.java index 4d1930ef5006..1de754d6d56c 100644 --- a/spring-context/src/main/java/org/springframework/context/event/EventListener.java +++ b/spring-context/src/main/java/org/springframework/context/event/EventListener.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,6 +21,7 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import java.util.function.Predicate; import org.springframework.context.ApplicationEvent; import org.springframework.core.annotation.AliasFor; @@ -128,4 +129,13 @@ */ String condition() default ""; + /** + * An optional identifier for the listener, defaulting to the fully-qualified + * signature of the declaring method (e.g. "mypackage.MyClass.myMethod()"). + * @since 5.3.5 + * @see org.springframework.context.ApplicationListener#getListenerId() + * @see ApplicationEventMulticaster#removeApplicationListeners(Predicate) + */ + String id() default ""; + } diff --git a/spring-context/src/main/java/org/springframework/context/event/GenericApplicationListener.java b/spring-context/src/main/java/org/springframework/context/event/GenericApplicationListener.java index 0df8d3acb20e..763f96f533af 100644 --- a/spring-context/src/main/java/org/springframework/context/event/GenericApplicationListener.java +++ b/spring-context/src/main/java/org/springframework/context/event/GenericApplicationListener.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,9 +18,7 @@ import org.springframework.context.ApplicationEvent; import org.springframework.context.ApplicationListener; -import org.springframework.core.Ordered; import org.springframework.core.ResolvableType; -import org.springframework.lang.Nullable; /** * Extended variant of the standard {@link ApplicationListener} interface, @@ -28,36 +26,31 @@ * *

As of Spring Framework 4.2, this interface supersedes the Class-based * {@link SmartApplicationListener} with full handling of generic event types. + * As of 5.3.5, it formally extends {@link SmartApplicationListener}, adapting + * {@link #supportsEventType(Class)} to {@link #supportsEventType(ResolvableType)} + * with a default method. * * @author Stephane Nicoll + * @author Juergen Hoeller * @since 4.2 * @see SmartApplicationListener * @see GenericApplicationListenerAdapter */ -public interface GenericApplicationListener extends ApplicationListener, Ordered { +public interface GenericApplicationListener extends SmartApplicationListener { /** - * Determine whether this listener actually supports the given event type. - * @param eventType the event type (never {@code null}) - */ - boolean supportsEventType(ResolvableType eventType); - - /** - * Determine whether this listener actually supports the given source type. - *

The default implementation always returns {@code true}. - * @param sourceType the source type, or {@code null} if no source + * Overrides {@link SmartApplicationListener#supportsEventType(Class)} with + * delegation to {@link #supportsEventType(ResolvableType)}. */ - default boolean supportsSourceType(@Nullable Class sourceType) { - return true; + @Override + default boolean supportsEventType(Class eventType) { + return supportsEventType(ResolvableType.forClass(eventType)); } /** - * Determine this listener's order in a set of listeners for the same event. - *

The default implementation returns {@link #LOWEST_PRECEDENCE}. + * Determine whether this listener actually supports the given event type. + * @param eventType the event type (never {@code null}) */ - @Override - default int getOrder() { - return LOWEST_PRECEDENCE; - } + boolean supportsEventType(ResolvableType eventType); } diff --git a/spring-context/src/main/java/org/springframework/context/event/GenericApplicationListenerAdapter.java b/spring-context/src/main/java/org/springframework/context/event/GenericApplicationListenerAdapter.java index b80d03545445..b48352f0b6a0 100644 --- a/spring-context/src/main/java/org/springframework/context/event/GenericApplicationListenerAdapter.java +++ b/spring-context/src/main/java/org/springframework/context/event/GenericApplicationListenerAdapter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -36,7 +36,7 @@ * @since 3.0 * @see org.springframework.context.ApplicationListener#onApplicationEvent */ -public class GenericApplicationListenerAdapter implements GenericApplicationListener, SmartApplicationListener { +public class GenericApplicationListenerAdapter implements GenericApplicationListener { private static final Map, ResolvableType> eventTypeCache = new ConcurrentReferenceHashMap<>(); @@ -67,7 +67,10 @@ public void onApplicationEvent(ApplicationEvent event) { @Override @SuppressWarnings("unchecked") public boolean supportsEventType(ResolvableType eventType) { - if (this.delegate instanceof SmartApplicationListener) { + if (this.delegate instanceof GenericApplicationListener) { + return ((GenericApplicationListener) this.delegate).supportsEventType(eventType); + } + else if (this.delegate instanceof SmartApplicationListener) { Class eventClass = (Class) eventType.resolve(); return (eventClass != null && ((SmartApplicationListener) this.delegate).supportsEventType(eventClass)); } @@ -76,11 +79,6 @@ public boolean supportsEventType(ResolvableType eventType) { } } - @Override - public boolean supportsEventType(Class eventType) { - return supportsEventType(ResolvableType.forClass(eventType)); - } - @Override public boolean supportsSourceType(@Nullable Class sourceType) { return !(this.delegate instanceof SmartApplicationListener) || @@ -92,6 +90,12 @@ public int getOrder() { return (this.delegate instanceof Ordered ? ((Ordered) this.delegate).getOrder() : Ordered.LOWEST_PRECEDENCE); } + @Override + public String getListenerId() { + return (this.delegate instanceof SmartApplicationListener ? + ((SmartApplicationListener) this.delegate).getListenerId() : ""); + } + @Nullable private static ResolvableType resolveDeclaredEventType(ApplicationListener listener) { diff --git a/spring-context/src/main/java/org/springframework/context/event/SmartApplicationListener.java b/spring-context/src/main/java/org/springframework/context/event/SmartApplicationListener.java index 548b67f7aa3e..c56dd33b336a 100644 --- a/spring-context/src/main/java/org/springframework/context/event/SmartApplicationListener.java +++ b/spring-context/src/main/java/org/springframework/context/event/SmartApplicationListener.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -59,4 +59,15 @@ default int getOrder() { return LOWEST_PRECEDENCE; } + /** + * Return an optional identifier for the listener. + *

The default value is an empty String. + * @since 5.3.5 + * @see EventListener#id + * @see ApplicationEventMulticaster#removeApplicationListeners + */ + default String getListenerId() { + return ""; + } + } diff --git a/spring-context/src/main/java/org/springframework/context/event/SourceFilteringListener.java b/spring-context/src/main/java/org/springframework/context/event/SourceFilteringListener.java index c6e52df056b1..a2593444ea7d 100644 --- a/spring-context/src/main/java/org/springframework/context/event/SourceFilteringListener.java +++ b/spring-context/src/main/java/org/springframework/context/event/SourceFilteringListener.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2017 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -34,7 +34,7 @@ * @author Stephane Nicoll * @since 2.0.5 */ -public class SourceFilteringListener implements GenericApplicationListener, SmartApplicationListener { +public class SourceFilteringListener implements GenericApplicationListener { private final Object source; @@ -79,11 +79,6 @@ public boolean supportsEventType(ResolvableType eventType) { return (this.delegate == null || this.delegate.supportsEventType(eventType)); } - @Override - public boolean supportsEventType(Class eventType) { - return supportsEventType(ResolvableType.forType(eventType)); - } - @Override public boolean supportsSourceType(@Nullable Class sourceType) { return (sourceType != null && sourceType.isInstance(this.source)); @@ -94,6 +89,11 @@ public int getOrder() { return (this.delegate != null ? this.delegate.getOrder() : Ordered.LOWEST_PRECEDENCE); } + @Override + public String getListenerId() { + return (this.delegate != null ? this.delegate.getListenerId() : ""); + } + /** * Actually process the event, after having filtered according to the diff --git a/spring-context/src/main/java/org/springframework/context/index/CandidateComponentsIndexLoader.java b/spring-context/src/main/java/org/springframework/context/index/CandidateComponentsIndexLoader.java index fcca537891a0..af068373d8c9 100644 --- a/spring-context/src/main/java/org/springframework/context/index/CandidateComponentsIndexLoader.java +++ b/spring-context/src/main/java/org/springframework/context/index/CandidateComponentsIndexLoader.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -48,13 +48,13 @@ public final class CandidateComponentsIndexLoader { public static final String COMPONENTS_RESOURCE_LOCATION = "META-INF/spring.components"; /** - * System property that instructs Spring to ignore the index, i.e. + * System property that instructs Spring to ignore the components index, i.e. * to always return {@code null} from {@link #loadIndex(ClassLoader)}. *

The default is "false", allowing for regular use of the index. Switching this * flag to {@code true} fulfills a corner case scenario when an index is partially * available for some libraries (or use cases) but couldn't be built for the whole * application. In this case, the application context fallbacks to a regular - * classpath arrangement (i.e. as no index was present at all). + * classpath arrangement (i.e. as though no index were present at all). */ public static final String IGNORE_INDEX = "spring.index.ignore"; diff --git a/spring-context/src/main/java/org/springframework/context/support/AbstractApplicationContext.java b/spring-context/src/main/java/org/springframework/context/support/AbstractApplicationContext.java index 86ca92ef6ffd..aca706e82c1f 100644 --- a/spring-context/src/main/java/org/springframework/context/support/AbstractApplicationContext.java +++ b/spring-context/src/main/java/org/springframework/context/support/AbstractApplicationContext.java @@ -46,6 +46,7 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisherAware; import org.springframework.context.ApplicationListener; +import org.springframework.context.ApplicationStartupAware; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.EmbeddedValueResolverAware; import org.springframework.context.EnvironmentAware; @@ -692,7 +693,7 @@ protected void prepareBeanFactory(ConfigurableListableBeanFactory beanFactory) { beanFactory.ignoreDependencyInterface(ApplicationEventPublisherAware.class); beanFactory.ignoreDependencyInterface(MessageSourceAware.class); beanFactory.ignoreDependencyInterface(ApplicationContextAware.class); - beanFactory.ignoreDependencyInterface(ApplicationStartup.class); + beanFactory.ignoreDependencyInterface(ApplicationStartupAware.class); // BeanFactory interface not registered as resolvable type in a plain factory. // MessageSource registered (and found for autowiring) as a bean. @@ -894,8 +895,8 @@ protected void finishBeanFactoryInitialization(ConfigurableListableBeanFactory b beanFactory.getBean(CONVERSION_SERVICE_BEAN_NAME, ConversionService.class)); } - // Register a default embedded value resolver if no bean post-processor - // (such as a PropertyPlaceholderConfigurer bean) registered any before: + // Register a default embedded value resolver if no BeanFactoryPostProcessor + // (such as a PropertySourcesPlaceholderConfigurer bean) registered any before: // at this point, primarily for resolution in annotation attribute values. if (!beanFactory.hasEmbeddedValueResolver()) { beanFactory.addEmbeddedValueResolver(strVal -> getEnvironment().resolvePlaceholders(strVal)); diff --git a/spring-context/src/main/java/org/springframework/format/annotation/DateTimeFormat.java b/spring-context/src/main/java/org/springframework/format/annotation/DateTimeFormat.java index 488e78d7da8e..54168efb5fef 100644 --- a/spring-context/src/main/java/org/springframework/format/annotation/DateTimeFormat.java +++ b/spring-context/src/main/java/org/springframework/format/annotation/DateTimeFormat.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,27 +26,44 @@ * Declares that a field or method parameter should be formatted as a date or time. * *

Supports formatting by style pattern, ISO date time pattern, or custom format pattern string. - * Can be applied to {@code java.util.Date}, {@code java.util.Calendar}, {@code Long} (for - * millisecond timestamps) as well as JSR-310 java.time and Joda-Time value types. + * Can be applied to {@link java.util.Date}, {@link java.util.Calendar}, {@link Long} (for + * millisecond timestamps) as well as JSR-310 {@code java.time} and Joda-Time value types. * - *

For style-based formatting, set the {@link #style} attribute to be the style pattern code. + *

For style-based formatting, set the {@link #style} attribute to the desired style pattern code. * The first character of the code is the date style, and the second character is the time style. * Specify a character of 'S' for short style, 'M' for medium, 'L' for long, and 'F' for full. - * A date or time may be omitted by specifying the style character '-'. + * The date or time may be omitted by specifying the style character '-' — for example, + * 'M-' specifies a medium format for the date with no time. * - *

For ISO-based formatting, set the {@link #iso} attribute to be the desired {@link ISO} format, - * such as {@link ISO#DATE}. For custom formatting, set the {@link #pattern} attribute to be the - * DateTime pattern, such as {@code yyyy/MM/dd hh:mm:ss a}. + *

For ISO-based formatting, set the {@link #iso} attribute to the desired {@link ISO} format, + * such as {@link ISO#DATE}. + * + *

For custom formatting, set the {@link #pattern} attribute to a date time pattern, such as + * {@code "yyyy/MM/dd hh:mm:ss a"}. * *

Each attribute is mutually exclusive, so only set one attribute per annotation instance - * (the one most convenient one for your formatting needs). - * When the pattern attribute is specified, it takes precedence over both the style and ISO attribute. - * When the {@link #iso} attribute is specified, it takes precedence over the style attribute. - * When no annotation attributes are specified, the default format applied is style-based - * with a style code of 'SS' (short date, short time). + * (the one most convenient for your formatting needs). + * + *

    + *
  • When the pattern attribute is specified, it takes precedence over both the style and ISO attribute.
  • + *
  • When the {@link #iso} attribute is specified, it takes precedence over the style attribute.
  • + *
  • When no annotation attributes are specified, the default format applied is style-based + * with a style code of 'SS' (short date, short time).
  • + *
+ * + *

Time Zones

+ *

Whenever the {@link #style} or {@link #pattern} attribute is used, the + * {@linkplain java.util.TimeZone#getDefault() default time zone} of the JVM will + * be used when formatting {@link java.util.Date} values. Whenever the {@link #iso} + * attribute is used when formatting {@link java.util.Date} values, {@code UTC} + * will be used as the time zone. The same time zone will be applied to any + * {@linkplain #fallbackPatterns fallback patterns} as well. In order to enforce + * consistent use of {@code UTC} as the time zone, you can bootstrap the JVM with + * {@code -Duser.timezone=UTC}. * * @author Keith Donald * @author Juergen Hoeller + * @author Sam Brannen * @since 3.0 * @see java.time.format.DateTimeFormatter * @see org.joda.time.format.DateTimeFormat @@ -57,34 +74,59 @@ public @interface DateTimeFormat { /** - * The style pattern to use to format the field. - *

Defaults to 'SS' for short date time. Set this attribute when you wish to format - * your field in accordance with a common style other than the default style. + * The style pattern to use to format the field or method parameter. + *

Defaults to 'SS' for short date, short time. Set this attribute when you + * wish to format your field or method parameter in accordance with a common + * style other than the default style. + * @see #fallbackPatterns */ String style() default "SS"; /** - * The ISO pattern to use to format the field. - *

The possible ISO patterns are defined in the {@link ISO} enum. + * The ISO pattern to use to format the field or method parameter. + *

Supported ISO patterns are defined in the {@link ISO} enum. *

Defaults to {@link ISO#NONE}, indicating this attribute should be ignored. - * Set this attribute when you wish to format your field in accordance with an ISO format. + * Set this attribute when you wish to format your field or method parameter + * in accordance with an ISO format. + * @see #fallbackPatterns */ ISO iso() default ISO.NONE; /** - * The custom pattern to use to format the field. - *

Defaults to empty String, indicating no custom pattern String has been specified. - * Set this attribute when you wish to format your field in accordance with a custom - * date time pattern not represented by a style or ISO format. + * The custom pattern to use to format the field or method parameter. + *

Defaults to empty String, indicating no custom pattern String has been + * specified. Set this attribute when you wish to format your field or method + * parameter in accordance with a custom date time pattern not represented by + * a style or ISO format. *

Note: This pattern follows the original {@link java.text.SimpleDateFormat} style, * as also supported by Joda-Time, with strict parsing semantics towards overflows * (e.g. rejecting a Feb 29 value for a non-leap-year). As a consequence, 'yy' * characters indicate a year in the traditional style, not a "year-of-era" as in the * {@link java.time.format.DateTimeFormatter} specification (i.e. 'yy' turns into 'uu' - * when going through that {@code DateTimeFormatter} with strict resolution mode). + * when going through a {@code DateTimeFormatter} with strict resolution mode). + * @see #fallbackPatterns */ String pattern() default ""; + /** + * The set of custom patterns to use as a fallback in case parsing fails for + * the primary {@link #pattern}, {@link #iso}, or {@link #style} attribute. + *

For example, if you wish to use the ISO date format for parsing and + * printing but allow for lenient parsing of user input for various date + * formats, you could configure something similar to the following. + *

+	 * {@literal @}DateTimeFormat(iso = ISO.DATE, fallbackPatterns = { "M/d/yy", "dd.MM.yyyy" })
+	 * 
+ *

Fallback patterns are only used for parsing. They are not used for + * printing the value as a String. The primary {@link #pattern}, {@link #iso}, + * or {@link #style} attribute is always used for printing. For details on + * which time zone is used for fallback patterns, see the + * {@linkplain DateTimeFormat class-level documentation}. + *

Fallback patterns are not supported for Joda-Time value types. + * @since 5.3.5 + */ + String[] fallbackPatterns() default {}; + /** * Common ISO date time format patterns. @@ -92,20 +134,20 @@ enum ISO { /** - * The most common ISO Date Format {@code yyyy-MM-dd}, - * e.g. "2000-10-31". + * The most common ISO Date Format {@code yyyy-MM-dd} — for example, + * "2000-10-31". */ DATE, /** - * The most common ISO Time Format {@code HH:mm:ss.SSSXXX}, - * e.g. "01:30:00.000-05:00". + * The most common ISO Time Format {@code HH:mm:ss.SSSXXX} — for example, + * "01:30:00.000-05:00". */ TIME, /** - * The most common ISO DateTime Format {@code yyyy-MM-dd'T'HH:mm:ss.SSSXXX}, - * e.g. "2000-10-31T01:30:00.000-05:00". + * The most common ISO Date Time Format {@code yyyy-MM-dd'T'HH:mm:ss.SSSXXX} + * — for example, "2000-10-31T01:30:00.000-05:00". */ DATE_TIME, diff --git a/spring-context/src/main/java/org/springframework/format/datetime/DateFormatter.java b/spring-context/src/main/java/org/springframework/format/datetime/DateFormatter.java index bef06848379b..d69e9c15e5fb 100644 --- a/spring-context/src/main/java/org/springframework/format/datetime/DateFormatter.java +++ b/spring-context/src/main/java/org/springframework/format/datetime/DateFormatter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -27,17 +27,21 @@ import java.util.TimeZone; import org.springframework.format.Formatter; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.format.annotation.DateTimeFormat.ISO; import org.springframework.lang.Nullable; +import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; /** * A formatter for {@link java.util.Date} types. - * Allows the configuration of an explicit date pattern and locale. + *

Supports the configuration of an explicit date time pattern, timezone, + * locale, and fallback date time patterns for lenient parsing. * * @author Keith Donald * @author Juergen Hoeller * @author Phillip Webb + * @author Sam Brannen * @since 3.0 * @see SimpleDateFormat */ @@ -56,9 +60,15 @@ public class DateFormatter implements Formatter { } + @Nullable + private Object source; + @Nullable private String pattern; + @Nullable + private String[] fallbackPatterns; + private int style = DateFormat.DEFAULT; @Nullable @@ -74,19 +84,33 @@ public class DateFormatter implements Formatter { /** - * Create a new default DateFormatter. + * Create a new default {@code DateFormatter}. */ public DateFormatter() { } /** - * Create a new DateFormatter for the given date pattern. + * Create a new {@code DateFormatter} for the given date time pattern. */ public DateFormatter(String pattern) { this.pattern = pattern; } + /** + * Set the source of the configuration for this {@code DateFormatter} — + * for example, an instance of the {@link DateTimeFormat @DateTimeFormat} + * annotation if such an annotation was used to configure this {@code DateFormatter}. + *

The supplied source object will only be used for descriptive purposes + * by invoking its {@code toString()} method — for example, when + * generating an exception message to provide further context. + * @param source the source of the configuration + * @since 5.3.5 + */ + public void setSource(Object source) { + this.source = source; + } + /** * Set the pattern to use to format date values. *

If not specified, DateFormat's default style will be used. @@ -96,7 +120,19 @@ public void setPattern(String pattern) { } /** - * Set the ISO format used for this date. + * Set additional patterns to use as a fallback in case parsing fails for the + * configured {@linkplain #setPattern pattern}, {@linkplain #setIso ISO format}, + * {@linkplain #setStyle style}, or {@linkplain #setStylePattern style pattern}. + * @param fallbackPatterns the fallback parsing patterns + * @since 5.3.5 + * @see DateTimeFormat#fallbackPatterns() + */ + public void setFallbackPatterns(String... fallbackPatterns) { + this.fallbackPatterns = fallbackPatterns; + } + + /** + * Set the ISO format to use to format date values. * @param iso the {@link ISO} format * @since 3.2 */ @@ -105,7 +141,7 @@ public void setIso(ISO iso) { } /** - * Set the style to use to format date values. + * Set the {@link DateFormat} style to use to format date values. *

If not specified, DateFormat's default style will be used. * @see DateFormat#DEFAULT * @see DateFormat#SHORT @@ -118,8 +154,10 @@ public void setStyle(int style) { } /** - * Set the two character to use to format date values. The first character used for - * the date style, the second is for the time style. Supported characters are + * Set the two characters to use to format date values. + *

The first character is used for the date style; the second is used for + * the time style. + *

Supported characters: *

    *
  • 'S' = Small
  • *
  • 'M' = Medium
  • @@ -136,7 +174,7 @@ public void setStylePattern(String stylePattern) { } /** - * Set the TimeZone to normalize the date values into, if any. + * Set the {@link TimeZone} to normalize the date values into, if any. */ public void setTimeZone(TimeZone timeZone) { this.timeZone = timeZone; @@ -159,12 +197,43 @@ public String print(Date date, Locale locale) { @Override public Date parse(String text, Locale locale) throws ParseException { - return getDateFormat(locale).parse(text); + try { + return getDateFormat(locale).parse(text); + } + catch (ParseException ex) { + if (!ObjectUtils.isEmpty(this.fallbackPatterns)) { + for (String pattern : this.fallbackPatterns) { + try { + DateFormat dateFormat = configureDateFormat(new SimpleDateFormat(pattern, locale)); + // Align timezone for parsing format with printing format if ISO is set. + if (this.iso != null && this.iso != ISO.NONE) { + dateFormat.setTimeZone(UTC); + } + return dateFormat.parse(text); + } + catch (ParseException ignoredException) { + // Ignore fallback parsing exceptions since the exception thrown below + // will include information from the "source" if available -- for example, + // the toString() of a @DateTimeFormat annotation. + } + } + } + if (this.source != null) { + throw new ParseException( + String.format("Unable to parse date time value \"%s\" using configuration from %s", text, this.source), + ex.getErrorOffset()); + } + // else rethrow original exception + throw ex; + } } protected DateFormat getDateFormat(Locale locale) { - DateFormat dateFormat = createDateFormat(locale); + return configureDateFormat(createDateFormat(locale)); + } + + private DateFormat configureDateFormat(DateFormat dateFormat) { if (this.timeZone != null) { dateFormat.setTimeZone(this.timeZone); } diff --git a/spring-context/src/main/java/org/springframework/format/datetime/DateTimeFormatAnnotationFormatterFactory.java b/spring-context/src/main/java/org/springframework/format/datetime/DateTimeFormatAnnotationFormatterFactory.java index 7b31fd6f4b09..4c46cd8d0bd0 100644 --- a/spring-context/src/main/java/org/springframework/format/datetime/DateTimeFormatAnnotationFormatterFactory.java +++ b/spring-context/src/main/java/org/springframework/format/datetime/DateTimeFormatAnnotationFormatterFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2017 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,10 +16,12 @@ package org.springframework.format.datetime; +import java.util.ArrayList; import java.util.Calendar; import java.util.Collections; import java.util.Date; import java.util.HashSet; +import java.util.List; import java.util.Set; import org.springframework.context.support.EmbeddedValueResolutionSupport; @@ -34,6 +36,7 @@ * Formats fields annotated with the {@link DateTimeFormat} annotation using a {@link DateFormatter}. * * @author Phillip Webb + * @author Sam Brannen * @since 3.2 * @see org.springframework.format.datetime.joda.JodaDateTimeFormatAnnotationFormatterFactory */ @@ -68,15 +71,30 @@ public Parser getParser(DateTimeFormat annotation, Class fieldType) { protected Formatter getFormatter(DateTimeFormat annotation, Class fieldType) { DateFormatter formatter = new DateFormatter(); + formatter.setSource(annotation); + formatter.setIso(annotation.iso()); + String style = resolveEmbeddedValue(annotation.style()); if (StringUtils.hasLength(style)) { formatter.setStylePattern(style); } - formatter.setIso(annotation.iso()); + String pattern = resolveEmbeddedValue(annotation.pattern()); if (StringUtils.hasLength(pattern)) { formatter.setPattern(pattern); } + + List resolvedFallbackPatterns = new ArrayList<>(); + for (String fallbackPattern : annotation.fallbackPatterns()) { + String resolvedFallbackPattern = resolveEmbeddedValue(fallbackPattern); + if (StringUtils.hasLength(resolvedFallbackPattern)) { + resolvedFallbackPatterns.add(resolvedFallbackPattern); + } + } + if (!resolvedFallbackPatterns.isEmpty()) { + formatter.setFallbackPatterns(resolvedFallbackPatterns.toArray(new String[0])); + } + return formatter; } diff --git a/spring-context/src/main/java/org/springframework/format/datetime/standard/DateTimeContext.java b/spring-context/src/main/java/org/springframework/format/datetime/standard/DateTimeContext.java index 1e79ec61a078..f64cdcb0539c 100644 --- a/spring-context/src/main/java/org/springframework/format/datetime/standard/DateTimeContext.java +++ b/spring-context/src/main/java/org/springframework/format/datetime/standard/DateTimeContext.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2017 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -29,7 +29,7 @@ /** * A context that holds user-specific java.time (JSR-310) settings * such as the user's Chronology (calendar system) and time zone. - * A {@code null} property value indicate the user has not specified a setting. + *

    A {@code null} property value indicates the user has not specified a setting. * * @author Juergen Hoeller * @since 4.0 @@ -81,8 +81,8 @@ public ZoneId getTimeZone() { /** - * Get the DateTimeFormatter with the this context's settings - * applied to the base {@code formatter}. + * Get the DateTimeFormatter with this context's settings applied to the + * base {@code formatter}. * @param formatter the base formatter that establishes default * formatting rules, generally context-independent * @return the contextual DateTimeFormatter diff --git a/spring-context/src/main/java/org/springframework/format/datetime/standard/DateTimeContextHolder.java b/spring-context/src/main/java/org/springframework/format/datetime/standard/DateTimeContextHolder.java index 8877419df9b6..aa5a5223ef30 100644 --- a/spring-context/src/main/java/org/springframework/format/datetime/standard/DateTimeContextHolder.java +++ b/spring-context/src/main/java/org/springframework/format/datetime/standard/DateTimeContextHolder.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -69,9 +69,8 @@ public static DateTimeContext getDateTimeContext() { return dateTimeContextHolder.get(); } - /** - * Obtain a DateTimeFormatter with user-specific settings applied to the given base Formatter. + * Obtain a DateTimeFormatter with user-specific settings applied to the given base formatter. * @param formatter the base formatter that establishes default formatting rules * (generally user independent) * @param locale the current user locale (may be {@code null} if not known) diff --git a/spring-context/src/main/java/org/springframework/format/datetime/standard/DateTimeFormatterFactory.java b/spring-context/src/main/java/org/springframework/format/datetime/standard/DateTimeFormatterFactory.java index 3d56f20987f3..c2fc3148dbc3 100644 --- a/spring-context/src/main/java/org/springframework/format/datetime/standard/DateTimeFormatterFactory.java +++ b/spring-context/src/main/java/org/springframework/format/datetime/standard/DateTimeFormatterFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,7 +18,6 @@ import java.time.format.DateTimeFormatter; import java.time.format.FormatStyle; -import java.time.format.ResolverStyle; import java.util.TimeZone; import org.springframework.format.annotation.DateTimeFormat.ISO; @@ -34,6 +33,7 @@ * * @author Juergen Hoeller * @author Phillip Webb + * @author Sam Brannen * @since 4.0 * @see #createDateTimeFormatter() * @see #createDateTimeFormatter(DateTimeFormatter) @@ -180,11 +180,7 @@ public DateTimeFormatter createDateTimeFormatter() { public DateTimeFormatter createDateTimeFormatter(DateTimeFormatter fallbackFormatter) { DateTimeFormatter dateTimeFormatter = null; if (StringUtils.hasLength(this.pattern)) { - // Using strict parsing to align with Joda-Time and standard DateFormat behavior: - // otherwise, an overflow like e.g. Feb 29 for a non-leap-year wouldn't get rejected. - // However, with strict parsing, a year digit needs to be specified as 'u'... - String patternToUse = StringUtils.replace(this.pattern, "yy", "uu"); - dateTimeFormatter = DateTimeFormatter.ofPattern(patternToUse).withResolverStyle(ResolverStyle.STRICT); + dateTimeFormatter = DateTimeFormatterUtils.createStrictDateTimeFormatter(this.pattern); } else if (this.iso != null && this.iso != ISO.NONE) { switch (this.iso) { diff --git a/spring-context/src/main/java/org/springframework/format/datetime/standard/DateTimeFormatterUtils.java b/spring-context/src/main/java/org/springframework/format/datetime/standard/DateTimeFormatterUtils.java new file mode 100644 index 000000000000..be767f0e23c9 --- /dev/null +++ b/spring-context/src/main/java/org/springframework/format/datetime/standard/DateTimeFormatterUtils.java @@ -0,0 +1,40 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.format.datetime.standard; + +import java.time.format.DateTimeFormatter; +import java.time.format.ResolverStyle; + +import org.springframework.util.StringUtils; + +/** + * Internal {@link DateTimeFormatter} utilities. + * + * @author Juergen Hoeller + * @since 5.3.5 + */ +abstract class DateTimeFormatterUtils { + + static DateTimeFormatter createStrictDateTimeFormatter(String pattern) { + // Using strict parsing to align with Joda-Time and standard DateFormat behavior: + // otherwise, an overflow like e.g. Feb 29 for a non-leap-year wouldn't get rejected. + // However, with strict parsing, a year digit needs to be specified as 'u'... + String patternToUse = StringUtils.replace(pattern, "yy", "uu"); + return DateTimeFormatter.ofPattern(patternToUse).withResolverStyle(ResolverStyle.STRICT); + } + +} diff --git a/spring-context/src/main/java/org/springframework/format/datetime/standard/Jsr310DateTimeFormatAnnotationFormatterFactory.java b/spring-context/src/main/java/org/springframework/format/datetime/standard/Jsr310DateTimeFormatAnnotationFormatterFactory.java index cd4d3a9d79bc..a1fd17fe21fb 100644 --- a/spring-context/src/main/java/org/springframework/format/datetime/standard/Jsr310DateTimeFormatAnnotationFormatterFactory.java +++ b/spring-context/src/main/java/org/springframework/format/datetime/standard/Jsr310DateTimeFormatAnnotationFormatterFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2017 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,8 +24,10 @@ import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.time.temporal.TemporalAccessor; +import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; +import java.util.List; import java.util.Set; import org.springframework.context.support.EmbeddedValueResolutionSupport; @@ -40,6 +42,7 @@ * JSR-310 java.time package in JDK 8. * * @author Juergen Hoeller + * @author Sam Brannen * @since 4.0 * @see org.springframework.format.annotation.DateTimeFormat */ @@ -93,8 +96,17 @@ else if (formatter == DateTimeFormatter.ISO_DATE_TIME) { @Override @SuppressWarnings("unchecked") public Parser getParser(DateTimeFormat annotation, Class fieldType) { + List resolvedFallbackPatterns = new ArrayList<>(); + for (String fallbackPattern : annotation.fallbackPatterns()) { + String resolvedFallbackPattern = resolveEmbeddedValue(fallbackPattern); + if (StringUtils.hasLength(resolvedFallbackPattern)) { + resolvedFallbackPatterns.add(resolvedFallbackPattern); + } + } + DateTimeFormatter formatter = getFormatter(annotation, fieldType); - return new TemporalAccessorParser((Class) fieldType, formatter); + return new TemporalAccessorParser((Class) fieldType, + formatter, resolvedFallbackPatterns.toArray(new String[0]), annotation); } /** diff --git a/spring-context/src/main/java/org/springframework/format/datetime/standard/TemporalAccessorParser.java b/spring-context/src/main/java/org/springframework/format/datetime/standard/TemporalAccessorParser.java index 4e135eebd8e6..b8dd20fe0191 100644 --- a/spring-context/src/main/java/org/springframework/format/datetime/standard/TemporalAccessorParser.java +++ b/spring-context/src/main/java/org/springframework/format/datetime/standard/TemporalAccessorParser.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2016 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,16 +24,20 @@ import java.time.OffsetTime; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; import java.time.temporal.TemporalAccessor; import java.util.Locale; import org.springframework.format.Parser; +import org.springframework.lang.Nullable; +import org.springframework.util.ObjectUtils; /** * {@link Parser} implementation for a JSR-310 {@link java.time.temporal.TemporalAccessor}, - * using a {@link java.time.format.DateTimeFormatter}) (the contextual one, if available). + * using a {@link java.time.format.DateTimeFormatter} (the contextual one, if available). * * @author Juergen Hoeller + * @author Sam Brannen * @since 4.0 * @see DateTimeContextHolder#getFormatter * @see java.time.LocalDate#parse(CharSequence, java.time.format.DateTimeFormatter) @@ -49,6 +53,12 @@ public final class TemporalAccessorParser implements Parser { private final DateTimeFormatter formatter; + @Nullable + private final String[] fallbackPatterns; + + @Nullable + private final Object source; + /** * Create a new TemporalAccessorParser for the given TemporalAccessor type. @@ -57,14 +67,49 @@ public final class TemporalAccessorParser implements Parser { * @param formatter the base DateTimeFormatter instance */ public TemporalAccessorParser(Class temporalAccessorType, DateTimeFormatter formatter) { + this(temporalAccessorType, formatter, null, null); + } + + TemporalAccessorParser(Class temporalAccessorType, DateTimeFormatter formatter, + @Nullable String[] fallbackPatterns, @Nullable Object source) { this.temporalAccessorType = temporalAccessorType; this.formatter = formatter; + this.fallbackPatterns = fallbackPatterns; + this.source = source; } @Override public TemporalAccessor parse(String text, Locale locale) throws ParseException { - DateTimeFormatter formatterToUse = DateTimeContextHolder.getFormatter(this.formatter, locale); + try { + return doParse(text, locale, this.formatter); + } + catch (DateTimeParseException ex) { + if (!ObjectUtils.isEmpty(this.fallbackPatterns)) { + for (String pattern : this.fallbackPatterns) { + try { + DateTimeFormatter fallbackFormatter = DateTimeFormatterUtils.createStrictDateTimeFormatter(pattern); + return doParse(text, locale, fallbackFormatter); + } + catch (DateTimeParseException ignoredException) { + // Ignore fallback parsing exceptions since the exception thrown below + // will include information from the "source" if available -- for example, + // the toString() of a @DateTimeFormat annotation. + } + } + } + if (this.source != null) { + throw new DateTimeParseException( + String.format("Unable to parse date time value \"%s\" using configuration from %s", text, this.source), + text, ex.getErrorIndex(), ex); + } + // else rethrow original exception + throw ex; + } + } + + private TemporalAccessor doParse(String text, Locale locale, DateTimeFormatter formatter) throws DateTimeParseException { + DateTimeFormatter formatterToUse = DateTimeContextHolder.getFormatter(formatter, locale); if (LocalDate.class == this.temporalAccessorType) { return LocalDate.parse(text, formatterToUse); } diff --git a/spring-context/src/main/java/org/springframework/scheduling/annotation/Scheduled.java b/spring-context/src/main/java/org/springframework/scheduling/annotation/Scheduled.java index ad8a477d380c..415c72381792 100644 --- a/spring-context/src/main/java/org/springframework/scheduling/annotation/Scheduled.java +++ b/spring-context/src/main/java/org/springframework/scheduling/annotation/Scheduled.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -85,7 +85,7 @@ * trigger, primarily meant for externally specified values resolved by a * ${...} placeholder. * @return an expression that can be parsed to a cron schedule - * @see org.springframework.scheduling.support.CronSequenceGenerator + * @see org.springframework.scheduling.support.CronExpression#parse(String) */ String cron() default ""; diff --git a/spring-context/src/main/java/org/springframework/scheduling/annotation/ScheduledAnnotationBeanPostProcessor.java b/spring-context/src/main/java/org/springframework/scheduling/annotation/ScheduledAnnotationBeanPostProcessor.java index 0ab8433a09ff..14b73d6d7f7c 100644 --- a/spring-context/src/main/java/org/springframework/scheduling/annotation/ScheduledAnnotationBeanPostProcessor.java +++ b/spring-context/src/main/java/org/springframework/scheduling/annotation/ScheduledAnnotationBeanPostProcessor.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -358,9 +358,9 @@ public Object postProcessAfterInitialization(Object bean, String beanName) { AnnotationUtils.isCandidateClass(targetClass, Arrays.asList(Scheduled.class, Schedules.class))) { Map> annotatedMethods = MethodIntrospector.selectMethods(targetClass, (MethodIntrospector.MetadataLookup>) method -> { - Set scheduledMethods = AnnotatedElementUtils.getMergedRepeatableAnnotations( + Set scheduledAnnotations = AnnotatedElementUtils.getMergedRepeatableAnnotations( method, Scheduled.class, Schedules.class); - return (!scheduledMethods.isEmpty() ? scheduledMethods : null); + return (!scheduledAnnotations.isEmpty() ? scheduledAnnotations : null); }); if (annotatedMethods.isEmpty()) { this.nonAnnotatedClasses.add(targetClass); @@ -370,8 +370,8 @@ public Object postProcessAfterInitialization(Object bean, String beanName) { } else { // Non-empty set of methods - annotatedMethods.forEach((method, scheduledMethods) -> - scheduledMethods.forEach(scheduled -> processScheduled(scheduled, method, bean))); + annotatedMethods.forEach((method, scheduledAnnotations) -> + scheduledAnnotations.forEach(scheduled -> processScheduled(scheduled, method, bean))); if (logger.isTraceEnabled()) { logger.trace(annotatedMethods.size() + " @Scheduled methods processed on bean '" + beanName + "': " + annotatedMethods); diff --git a/spring-context/src/main/java/org/springframework/scheduling/config/CronTask.java b/spring-context/src/main/java/org/springframework/scheduling/config/CronTask.java index c011222cda68..a8af8ed36207 100644 --- a/spring-context/src/main/java/org/springframework/scheduling/config/CronTask.java +++ b/spring-context/src/main/java/org/springframework/scheduling/config/CronTask.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2017 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,8 +20,8 @@ /** * {@link TriggerTask} implementation defining a {@code Runnable} to be executed according - * to a {@linkplain org.springframework.scheduling.support.CronSequenceGenerator standard - * cron expression}. + * to a {@linkplain org.springframework.scheduling.support.CronExpression#parse(String) + * standard cron expression}. * * @author Chris Beams * @since 3.2 diff --git a/spring-context/src/main/java/org/springframework/scheduling/support/BitsCronField.java b/spring-context/src/main/java/org/springframework/scheduling/support/BitsCronField.java index 1cb0dca6b47d..947e3217612e 100644 --- a/spring-context/src/main/java/org/springframework/scheduling/support/BitsCronField.java +++ b/spring-context/src/main/java/org/springframework/scheduling/support/BitsCronField.java @@ -165,6 +165,10 @@ private static ValueRange parseRange(String value, Type type) { int max = Integer.parseInt(value.substring(hyphenPos + 1)); min = type.checkValidValue(min); max = type.checkValidValue(max); + if (type == Type.DAY_OF_WEEK && min == 7) { + // If used as a minimum in a range, Sunday means 0 (not 7) + min = 0; + } return ValueRange.of(min, max); } } diff --git a/spring-context/src/main/java/org/springframework/scheduling/support/CronField.java b/spring-context/src/main/java/org/springframework/scheduling/support/CronField.java index 0ed567a24e2d..d5dee884d6b0 100644 --- a/spring-context/src/main/java/org/springframework/scheduling/support/CronField.java +++ b/spring-context/src/main/java/org/springframework/scheduling/support/CronField.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -237,7 +237,12 @@ public int checkValidValue(int value) { public > T elapseUntil(T temporal, int goal) { int current = get(temporal); if (current < goal) { - return this.field.getBaseUnit().addTo(temporal, goal - current); + T result = this.field.getBaseUnit().addTo(temporal, goal - current); + current = get(result); + if (current > goal) { // can occur due to daylight saving, see gh-26744 + result = this.field.getBaseUnit().addTo(result, goal - current); + } + return result; } else { ValueRange range = temporal.range(this.field); diff --git a/spring-context/src/main/resources/org/springframework/scheduling/config/spring-task.xsd b/spring-context/src/main/resources/org/springframework/scheduling/config/spring-task.xsd index 414a2a35865a..d33af8ab0997 100644 --- a/spring-context/src/main/resources/org/springframework/scheduling/config/spring-task.xsd +++ b/spring-context/src/main/resources/org/springframework/scheduling/config/spring-task.xsd @@ -244,7 +244,7 @@ diff --git a/spring-context/src/test/java/org/springframework/context/event/AnnotationDrivenEventListenerTests.java b/spring-context/src/test/java/org/springframework/context/event/AnnotationDrivenEventListenerTests.java index 95ead7c1d922..cd1e71a12d57 100644 --- a/spring-context/src/test/java/org/springframework/context/event/AnnotationDrivenEventListenerTests.java +++ b/spring-context/src/test/java/org/springframework/context/event/AnnotationDrivenEventListenerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -111,6 +111,12 @@ public void simpleEventJavaConfig() { this.context.publishEvent(event); this.eventCollector.assertEvent(listener, event); this.eventCollector.assertTotalEventsCount(1); + + context.getBean(ApplicationEventMulticaster.class).removeApplicationListeners(l -> + l instanceof SmartApplicationListener && ((SmartApplicationListener) l).getListenerId().contains("TestEvent")); + this.eventCollector.clear(); + this.context.publishEvent(event); + this.eventCollector.assertNoEventReceived(listener); } @Test @@ -126,6 +132,12 @@ public void simpleEventXmlConfig() { this.context.publishEvent(event); this.eventCollector.assertEvent(listener, event); this.eventCollector.assertTotalEventsCount(1); + + context.getBean(ApplicationEventMulticaster.class).removeApplicationListeners(l -> + l instanceof SmartApplicationListener && ((SmartApplicationListener) l).getListenerId().contains("TestEvent")); + this.eventCollector.clear(); + this.context.publishEvent(event); + this.eventCollector.assertNoEventReceived(listener); } @Test @@ -138,6 +150,12 @@ public void metaAnnotationIsDiscovered() { this.context.publishEvent(event); this.eventCollector.assertEvent(bean, event); this.eventCollector.assertTotalEventsCount(1); + + context.getBean(ApplicationEventMulticaster.class).removeApplicationListeners(l -> + l instanceof SmartApplicationListener && ((SmartApplicationListener) l).getListenerId().equals("foo")); + this.eventCollector.clear(); + this.context.publishEvent(event); + this.eventCollector.assertNoEventReceived(bean); } @Test @@ -711,7 +729,7 @@ public void handleString(String content) { } - @EventListener + @EventListener(id = "foo") @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @interface FooListener { diff --git a/spring-context/src/test/java/org/springframework/format/datetime/DateFormattingTests.java b/spring-context/src/test/java/org/springframework/format/datetime/DateFormattingTests.java index 30b45b131749..ebfbc694dc51 100644 --- a/spring-context/src/test/java/org/springframework/format/datetime/DateFormattingTests.java +++ b/spring-context/src/test/java/org/springframework/format/datetime/DateFormattingTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,7 @@ package org.springframework.format.datetime; +import java.text.ParseException; import java.util.ArrayList; import java.util.Calendar; import java.util.Date; @@ -25,16 +26,23 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.MutablePropertyValues; +import org.springframework.beans.TypeMismatchException; import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.core.convert.ConversionFailedException; import org.springframework.core.convert.TypeDescriptor; import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.format.annotation.DateTimeFormat.ISO; import org.springframework.format.support.FormattingConversionService; +import org.springframework.validation.BindingResult; import org.springframework.validation.DataBinder; +import org.springframework.validation.FieldError; import static org.assertj.core.api.Assertions.assertThat; @@ -42,10 +50,11 @@ * @author Phillip Webb * @author Keith Donald * @author Juergen Hoeller + * @author Sam Brannen */ public class DateFormattingTests { - private FormattingConversionService conversionService; + private final FormattingConversionService conversionService = new FormattingConversionService(); private DataBinder binder; @@ -57,7 +66,6 @@ void setup() { } private void setup(DateFormatterRegistrar registrar) { - conversionService = new FormattingConversionService(); DefaultConversionService.addDefaultConverters(conversionService); registrar.registerFormatters(conversionService); @@ -87,34 +95,34 @@ void testBindLong() { @Test void testBindLongAnnotated() { MutablePropertyValues propertyValues = new MutablePropertyValues(); - propertyValues.add("millisAnnotated", "10/31/09"); + propertyValues.add("styleMillis", "10/31/09"); binder.bind(propertyValues); assertThat(binder.getBindingResult().getErrorCount()).isEqualTo(0); - assertThat(binder.getBindingResult().getFieldValue("millisAnnotated")).isEqualTo("10/31/09"); + assertThat(binder.getBindingResult().getFieldValue("styleMillis")).isEqualTo("10/31/09"); } @Test void testBindCalendarAnnotated() { MutablePropertyValues propertyValues = new MutablePropertyValues(); - propertyValues.add("calendarAnnotated", "10/31/09"); + propertyValues.add("styleCalendar", "10/31/09"); binder.bind(propertyValues); assertThat(binder.getBindingResult().getErrorCount()).isEqualTo(0); - assertThat(binder.getBindingResult().getFieldValue("calendarAnnotated")).isEqualTo("10/31/09"); + assertThat(binder.getBindingResult().getFieldValue("styleCalendar")).isEqualTo("10/31/09"); } @Test void testBindDateAnnotated() { MutablePropertyValues propertyValues = new MutablePropertyValues(); - propertyValues.add("dateAnnotated", "10/31/09"); + propertyValues.add("styleDate", "10/31/09"); binder.bind(propertyValues); assertThat(binder.getBindingResult().getErrorCount()).isEqualTo(0); - assertThat(binder.getBindingResult().getFieldValue("dateAnnotated")).isEqualTo("10/31/09"); + assertThat(binder.getBindingResult().getFieldValue("styleDate")).isEqualTo("10/31/09"); } @Test void testBindDateArray() { MutablePropertyValues propertyValues = new MutablePropertyValues(); - propertyValues.add("dateAnnotated", new String[]{"10/31/09 12:00 PM"}); + propertyValues.add("styleDate", new String[]{"10/31/09 12:00 PM"}); binder.bind(propertyValues); assertThat(binder.getBindingResult().getErrorCount()).isEqualTo(0); } @@ -122,10 +130,10 @@ void testBindDateArray() { @Test void testBindDateAnnotatedWithError() { MutablePropertyValues propertyValues = new MutablePropertyValues(); - propertyValues.add("dateAnnotated", "Oct X31, 2009"); + propertyValues.add("styleDate", "Oct X31, 2009"); binder.bind(propertyValues); - assertThat(binder.getBindingResult().getFieldErrorCount("dateAnnotated")).isEqualTo(1); - assertThat(binder.getBindingResult().getFieldValue("dateAnnotated")).isEqualTo("Oct X31, 2009"); + assertThat(binder.getBindingResult().getFieldErrorCount("styleDate")).isEqualTo(1); + assertThat(binder.getBindingResult().getFieldValue("styleDate")).isEqualTo("Oct X31, 2009"); } @Test @@ -133,19 +141,19 @@ void testBindDateAnnotatedWithError() { void testBindDateAnnotatedWithFallbackError() { // TODO This currently passes because of the Date(String) constructor fallback is used MutablePropertyValues propertyValues = new MutablePropertyValues(); - propertyValues.add("dateAnnotated", "Oct 031, 2009"); + propertyValues.add("styleDate", "Oct 031, 2009"); binder.bind(propertyValues); - assertThat(binder.getBindingResult().getFieldErrorCount("dateAnnotated")).isEqualTo(1); - assertThat(binder.getBindingResult().getFieldValue("dateAnnotated")).isEqualTo("Oct 031, 2009"); + assertThat(binder.getBindingResult().getFieldErrorCount("styleDate")).isEqualTo(1); + assertThat(binder.getBindingResult().getFieldValue("styleDate")).isEqualTo("Oct 031, 2009"); } @Test void testBindDateAnnotatedPattern() { MutablePropertyValues propertyValues = new MutablePropertyValues(); - propertyValues.add("dateAnnotatedPattern", "10/31/09 1:05"); + propertyValues.add("patternDate", "10/31/09 1:05"); binder.bind(propertyValues); assertThat(binder.getBindingResult().getErrorCount()).isEqualTo(0); - assertThat(binder.getBindingResult().getFieldValue("dateAnnotatedPattern")).isEqualTo("10/31/09 1:05"); + assertThat(binder.getBindingResult().getFieldValue("patternDate")).isEqualTo("10/31/09 1:05"); } @Test @@ -156,16 +164,17 @@ void testBindDateAnnotatedPatternWithGlobalFormat() { registrar.setFormatter(dateFormatter); setup(registrar); MutablePropertyValues propertyValues = new MutablePropertyValues(); - propertyValues.add("dateAnnotatedPattern", "10/31/09 1:05"); + propertyValues.add("patternDate", "10/31/09 1:05"); binder.bind(propertyValues); - assertThat(binder.getBindingResult().getErrorCount()).isEqualTo(0); - assertThat(binder.getBindingResult().getFieldValue("dateAnnotatedPattern")).isEqualTo("10/31/09 1:05"); + BindingResult bindingResult = binder.getBindingResult(); + assertThat(bindingResult.getErrorCount()).isEqualTo(0); + assertThat(bindingResult.getFieldValue("patternDate")).isEqualTo("10/31/09 1:05"); } @Test void testBindDateTimeOverflow() { MutablePropertyValues propertyValues = new MutablePropertyValues(); - propertyValues.add("dateAnnotatedPattern", "02/29/09 12:00 PM"); + propertyValues.add("patternDate", "02/29/09 12:00 PM"); binder.bind(propertyValues); assertThat(binder.getBindingResult().getErrorCount()).isEqualTo(1); } @@ -200,10 +209,10 @@ void testBindISODateTime() { @Test void testBindNestedDateAnnotated() { MutablePropertyValues propertyValues = new MutablePropertyValues(); - propertyValues.add("children[0].dateAnnotated", "10/31/09"); + propertyValues.add("children[0].styleDate", "10/31/09"); binder.bind(propertyValues); assertThat(binder.getBindingResult().getErrorCount()).isEqualTo(0); - assertThat(binder.getBindingResult().getFieldValue("children[0].dateAnnotated")).isEqualTo("10/31/09"); + assertThat(binder.getBindingResult().getFieldValue("children[0].styleDate")).isEqualTo("10/31/09"); } @Test @@ -247,35 +256,127 @@ void stringToDateWithGlobalFormat() { } + @Nested + class FallbackPatternTests { + + @ParameterizedTest(name = "input date: {0}") + @ValueSource(strings = {"2021-03-02", "2021.03.02", "20210302", "3/2/21"}) + void styleCalendar(String propertyValue) { + String propertyName = "styleCalendarWithFallbackPatterns"; + MutablePropertyValues propertyValues = new MutablePropertyValues(); + propertyValues.add(propertyName, propertyValue); + binder.bind(propertyValues); + BindingResult bindingResult = binder.getBindingResult(); + assertThat(bindingResult.getErrorCount()).isEqualTo(0); + assertThat(bindingResult.getFieldValue(propertyName)).isEqualTo("3/2/21"); + } + + @ParameterizedTest(name = "input date: {0}") + @ValueSource(strings = {"2021-03-02", "2021.03.02", "20210302", "3/2/21"}) + void styleDate(String propertyValue) { + String propertyName = "styleDateWithFallbackPatterns"; + MutablePropertyValues propertyValues = new MutablePropertyValues(); + propertyValues.add(propertyName, propertyValue); + binder.bind(propertyValues); + BindingResult bindingResult = binder.getBindingResult(); + assertThat(bindingResult.getErrorCount()).isEqualTo(0); + assertThat(bindingResult.getFieldValue(propertyName)).isEqualTo("3/2/21"); + } + + @ParameterizedTest(name = "input date: {0}") + @ValueSource(strings = {"2021-03-02", "2021.03.02", "20210302", "3/2/21"}) + void patternDate(String propertyValue) { + String propertyName = "patternDateWithFallbackPatterns"; + MutablePropertyValues propertyValues = new MutablePropertyValues(); + propertyValues.add(propertyName, propertyValue); + binder.bind(propertyValues); + BindingResult bindingResult = binder.getBindingResult(); + assertThat(bindingResult.getErrorCount()).isEqualTo(0); + assertThat(bindingResult.getFieldValue(propertyName)).isEqualTo("2021-03-02"); + } + + @ParameterizedTest(name = "input date: {0}") + @ValueSource(strings = {"2021-03-02", "2021.03.02", "20210302", "3/2/21"}) + void isoDate(String propertyValue) { + String propertyName = "isoDateWithFallbackPatterns"; + MutablePropertyValues propertyValues = new MutablePropertyValues(); + propertyValues.add(propertyName, propertyValue); + binder.bind(propertyValues); + BindingResult bindingResult = binder.getBindingResult(); + assertThat(bindingResult.getErrorCount()).isEqualTo(0); + assertThat(bindingResult.getFieldValue(propertyName)).isEqualTo("2021-03-02"); + } + + @Test + void patternDateWithUnsupportedPattern() { + String propertyValue = "210302"; + String propertyName = "patternDateWithFallbackPatterns"; + MutablePropertyValues propertyValues = new MutablePropertyValues(); + propertyValues.add(propertyName, propertyValue); + binder.bind(propertyValues); + BindingResult bindingResult = binder.getBindingResult(); + assertThat(bindingResult.getErrorCount()).isEqualTo(1); + FieldError fieldError = bindingResult.getFieldError(propertyName); + assertThat(fieldError.unwrap(TypeMismatchException.class)) + .hasMessageContaining("for property 'patternDateWithFallbackPatterns'") + .hasCauseInstanceOf(ConversionFailedException.class).getCause() + .hasMessageContaining("for value '210302'") + .hasCauseInstanceOf(IllegalArgumentException.class).getCause() + .hasMessageContaining("Parse attempt failed for value [210302]") + .hasCauseInstanceOf(ParseException.class).getCause() + // Unable to parse date time value "210302" using configuration from + // @org.springframework.format.annotation.DateTimeFormat( + // pattern=yyyy-MM-dd, style=SS, iso=NONE, fallbackPatterns=[M/d/yy, yyyyMMdd, yyyy.MM.dd]) + .hasMessageContainingAll( + "Unable to parse date time value \"210302\" using configuration from", + "@org.springframework.format.annotation.DateTimeFormat", + "yyyy-MM-dd", "M/d/yy", "yyyyMMdd", "yyyy.MM.dd"); + } + } + + @SuppressWarnings("unused") private static class SimpleDateBean { private Long millis; - private Long millisAnnotated; + private Long styleMillis; - @DateTimeFormat(style="S-") - private Calendar calendarAnnotated; + @DateTimeFormat(style = "S-") + private Calendar styleCalendar; - @DateTimeFormat(style="S-") - private Date dateAnnotated; + @DateTimeFormat(style = "S-", fallbackPatterns = { "yyyy-MM-dd", "yyyyMMdd", "yyyy.MM.dd" }) + private Calendar styleCalendarWithFallbackPatterns; + + @DateTimeFormat(style = "S-") + private Date styleDate; + + @DateTimeFormat(style = "S-", fallbackPatterns = { "yyyy-MM-dd", "yyyyMMdd", "yyyy.MM.dd" }) + private Date styleDateWithFallbackPatterns; + + @DateTimeFormat(pattern = "M/d/yy h:mm") + private Date patternDate; - @DateTimeFormat(pattern="M/d/yy h:mm") - private Date dateAnnotatedPattern; + @DateTimeFormat(pattern = "yyyy-MM-dd", fallbackPatterns = { "M/d/yy", "yyyyMMdd", "yyyy.MM.dd" }) + private Date patternDateWithFallbackPatterns; - @DateTimeFormat(iso=ISO.DATE) + @DateTimeFormat(iso = ISO.DATE) private Date isoDate; - @DateTimeFormat(iso=ISO.TIME) + @DateTimeFormat(iso = ISO.DATE, fallbackPatterns = { "M/d/yy", "yyyyMMdd", "yyyy.MM.dd" }) + private Date isoDateWithFallbackPatterns; + + @DateTimeFormat(iso = ISO.TIME) private Date isoTime; - @DateTimeFormat(iso=ISO.DATE_TIME) + @DateTimeFormat(iso = ISO.DATE_TIME) private Date isoDateTime; private final List children = new ArrayList<>(); + public Long getMillis() { - return millis; + return this.millis; } public void setMillis(Long millis) { @@ -283,48 +384,80 @@ public void setMillis(Long millis) { } @DateTimeFormat(style="S-") - public Long getMillisAnnotated() { - return millisAnnotated; + public Long getStyleMillis() { + return this.styleMillis; + } + + public void setStyleMillis(@DateTimeFormat(style="S-") Long styleMillis) { + this.styleMillis = styleMillis; + } + + public Calendar getStyleCalendar() { + return this.styleCalendar; + } + + public void setStyleCalendar(Calendar styleCalendar) { + this.styleCalendar = styleCalendar; + } + + public Calendar getStyleCalendarWithFallbackPatterns() { + return this.styleCalendarWithFallbackPatterns; + } + + public void setStyleCalendarWithFallbackPatterns(Calendar styleCalendarWithFallbackPatterns) { + this.styleCalendarWithFallbackPatterns = styleCalendarWithFallbackPatterns; + } + + public Date getStyleDate() { + return this.styleDate; } - public void setMillisAnnotated(@DateTimeFormat(style="S-") Long millisAnnotated) { - this.millisAnnotated = millisAnnotated; + public void setStyleDate(Date styleDate) { + this.styleDate = styleDate; } - public Calendar getCalendarAnnotated() { - return calendarAnnotated; + public Date getStyleDateWithFallbackPatterns() { + return this.styleDateWithFallbackPatterns; } - public void setCalendarAnnotated(Calendar calendarAnnotated) { - this.calendarAnnotated = calendarAnnotated; + public void setStyleDateWithFallbackPatterns(Date styleDateWithFallbackPatterns) { + this.styleDateWithFallbackPatterns = styleDateWithFallbackPatterns; } - public Date getDateAnnotated() { - return dateAnnotated; + public Date getPatternDate() { + return this.patternDate; } - public void setDateAnnotated(Date dateAnnotated) { - this.dateAnnotated = dateAnnotated; + public void setPatternDate(Date patternDate) { + this.patternDate = patternDate; } - public Date getDateAnnotatedPattern() { - return dateAnnotatedPattern; + public Date getPatternDateWithFallbackPatterns() { + return this.patternDateWithFallbackPatterns; } - public void setDateAnnotatedPattern(Date dateAnnotatedPattern) { - this.dateAnnotatedPattern = dateAnnotatedPattern; + public void setPatternDateWithFallbackPatterns(Date patternDateWithFallbackPatterns) { + this.patternDateWithFallbackPatterns = patternDateWithFallbackPatterns; } public Date getIsoDate() { - return isoDate; + return this.isoDate; } public void setIsoDate(Date isoDate) { this.isoDate = isoDate; } + public Date getIsoDateWithFallbackPatterns() { + return this.isoDateWithFallbackPatterns; + } + + public void setIsoDateWithFallbackPatterns(Date isoDateWithFallbackPatterns) { + this.isoDateWithFallbackPatterns = isoDateWithFallbackPatterns; + } + public Date getIsoTime() { - return isoTime; + return this.isoTime; } public void setIsoTime(Date isoTime) { @@ -332,7 +465,7 @@ public void setIsoTime(Date isoTime) { } public Date getIsoDateTime() { - return isoDateTime; + return this.isoDateTime; } public void setIsoDateTime(Date isoDateTime) { @@ -340,7 +473,7 @@ public void setIsoDateTime(Date isoDateTime) { } public List getChildren() { - return children; + return this.children; } } diff --git a/spring-context/src/test/java/org/springframework/format/datetime/standard/DateTimeFormattingTests.java b/spring-context/src/test/java/org/springframework/format/datetime/standard/DateTimeFormattingTests.java index 23f640cb9410..6aa28756f686 100644 --- a/spring-context/src/test/java/org/springframework/format/datetime/standard/DateTimeFormattingTests.java +++ b/spring-context/src/test/java/org/springframework/format/datetime/standard/DateTimeFormattingTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,7 @@ package org.springframework.format.datetime.standard; +import java.time.DateTimeException; import java.time.Duration; import java.time.Instant; import java.time.LocalDate; @@ -28,6 +29,7 @@ import java.time.YearMonth; import java.time.ZoneId; import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; import java.time.format.FormatStyle; import java.util.ArrayList; import java.util.Date; @@ -38,15 +40,22 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.MutablePropertyValues; +import org.springframework.beans.TypeMismatchException; import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.core.convert.ConversionFailedException; import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.format.annotation.DateTimeFormat.ISO; import org.springframework.format.support.FormattingConversionService; +import org.springframework.validation.BindingResult; import org.springframework.validation.DataBinder; +import org.springframework.validation.FieldError; import static org.assertj.core.api.Assertions.assertThat; @@ -54,22 +63,22 @@ * @author Keith Donald * @author Juergen Hoeller * @author Phillip Webb + * @author Sam Brannen */ -public class DateTimeFormattingTests { +class DateTimeFormattingTests { - private FormattingConversionService conversionService; + private final FormattingConversionService conversionService = new FormattingConversionService(); private DataBinder binder; @BeforeEach - public void setup() { + void setup() { DateTimeFormatterRegistrar registrar = new DateTimeFormatterRegistrar(); setup(registrar); } private void setup(DateTimeFormatterRegistrar registrar) { - conversionService = new FormattingConversionService(); DefaultConversionService.addDefaultConverters(conversionService); registrar.registerFormatters(conversionService); @@ -85,14 +94,14 @@ private void setup(DateTimeFormatterRegistrar registrar) { } @AfterEach - public void cleanup() { + void cleanup() { LocaleContextHolder.setLocale(null); DateTimeContextHolder.setDateTimeContext(null); } @Test - public void testBindLocalDate() { + void testBindLocalDate() { MutablePropertyValues propertyValues = new MutablePropertyValues(); propertyValues.add("localDate", "10/31/09"); binder.bind(propertyValues); @@ -101,7 +110,7 @@ public void testBindLocalDate() { } @Test - public void testBindLocalDateWithSpecificStyle() { + void testBindLocalDateWithSpecificStyle() { DateTimeFormatterRegistrar registrar = new DateTimeFormatterRegistrar(); registrar.setDateStyle(FormatStyle.LONG); setup(registrar); @@ -113,7 +122,7 @@ public void testBindLocalDateWithSpecificStyle() { } @Test - public void testBindLocalDateWithSpecificFormatter() { + void testBindLocalDateWithSpecificFormatter() { DateTimeFormatterRegistrar registrar = new DateTimeFormatterRegistrar(); registrar.setDateFormatter(DateTimeFormatter.ofPattern("yyyyMMdd")); setup(registrar); @@ -125,7 +134,7 @@ public void testBindLocalDateWithSpecificFormatter() { } @Test - public void testBindLocalDateArray() { + void testBindLocalDateArray() { MutablePropertyValues propertyValues = new MutablePropertyValues(); propertyValues.add("localDate", new String[] {"10/31/09"}); binder.bind(propertyValues); @@ -133,54 +142,54 @@ public void testBindLocalDateArray() { } @Test - public void testBindLocalDateAnnotated() { + void testBindLocalDateAnnotated() { MutablePropertyValues propertyValues = new MutablePropertyValues(); - propertyValues.add("localDateAnnotated", "Oct 31, 2009"); + propertyValues.add("styleLocalDate", "Oct 31, 2009"); binder.bind(propertyValues); assertThat(binder.getBindingResult().getErrorCount()).isEqualTo(0); - assertThat(binder.getBindingResult().getFieldValue("localDateAnnotated")).isEqualTo("Oct 31, 2009"); + assertThat(binder.getBindingResult().getFieldValue("styleLocalDate")).isEqualTo("Oct 31, 2009"); } @Test - public void testBindLocalDateAnnotatedWithError() { + void testBindLocalDateAnnotatedWithError() { MutablePropertyValues propertyValues = new MutablePropertyValues(); - propertyValues.add("localDateAnnotated", "Oct -31, 2009"); + propertyValues.add("styleLocalDate", "Oct -31, 2009"); binder.bind(propertyValues); - assertThat(binder.getBindingResult().getFieldErrorCount("localDateAnnotated")).isEqualTo(1); - assertThat(binder.getBindingResult().getFieldValue("localDateAnnotated")).isEqualTo("Oct -31, 2009"); + assertThat(binder.getBindingResult().getFieldErrorCount("styleLocalDate")).isEqualTo(1); + assertThat(binder.getBindingResult().getFieldValue("styleLocalDate")).isEqualTo("Oct -31, 2009"); } @Test - public void testBindNestedLocalDateAnnotated() { + void testBindNestedLocalDateAnnotated() { MutablePropertyValues propertyValues = new MutablePropertyValues(); - propertyValues.add("children[0].localDateAnnotated", "Oct 31, 2009"); + propertyValues.add("children[0].styleLocalDate", "Oct 31, 2009"); binder.bind(propertyValues); assertThat(binder.getBindingResult().getErrorCount()).isEqualTo(0); - assertThat(binder.getBindingResult().getFieldValue("children[0].localDateAnnotated")).isEqualTo("Oct 31, 2009"); + assertThat(binder.getBindingResult().getFieldValue("children[0].styleLocalDate")).isEqualTo("Oct 31, 2009"); } @Test - public void testBindLocalDateAnnotatedWithDirectFieldAccess() { + void testBindLocalDateAnnotatedWithDirectFieldAccess() { binder.initDirectFieldAccess(); MutablePropertyValues propertyValues = new MutablePropertyValues(); - propertyValues.add("localDateAnnotated", "Oct 31, 2009"); + propertyValues.add("styleLocalDate", "Oct 31, 2009"); binder.bind(propertyValues); assertThat(binder.getBindingResult().getErrorCount()).isEqualTo(0); - assertThat(binder.getBindingResult().getFieldValue("localDateAnnotated")).isEqualTo("Oct 31, 2009"); + assertThat(binder.getBindingResult().getFieldValue("styleLocalDate")).isEqualTo("Oct 31, 2009"); } @Test - public void testBindLocalDateAnnotatedWithDirectFieldAccessAndError() { + void testBindLocalDateAnnotatedWithDirectFieldAccessAndError() { binder.initDirectFieldAccess(); MutablePropertyValues propertyValues = new MutablePropertyValues(); - propertyValues.add("localDateAnnotated", "Oct -31, 2009"); + propertyValues.add("styleLocalDate", "Oct -31, 2009"); binder.bind(propertyValues); - assertThat(binder.getBindingResult().getFieldErrorCount("localDateAnnotated")).isEqualTo(1); - assertThat(binder.getBindingResult().getFieldValue("localDateAnnotated")).isEqualTo("Oct -31, 2009"); + assertThat(binder.getBindingResult().getFieldErrorCount("styleLocalDate")).isEqualTo(1); + assertThat(binder.getBindingResult().getFieldValue("styleLocalDate")).isEqualTo("Oct -31, 2009"); } @Test - public void testBindLocalDateFromJavaUtilCalendar() { + void testBindLocalDateFromJavaUtilCalendar() { MutablePropertyValues propertyValues = new MutablePropertyValues(); propertyValues.add("localDate", new GregorianCalendar(2009, 9, 31, 0, 0)); binder.bind(propertyValues); @@ -189,7 +198,7 @@ public void testBindLocalDateFromJavaUtilCalendar() { } @Test - public void testBindLocalTime() { + void testBindLocalTime() { MutablePropertyValues propertyValues = new MutablePropertyValues(); propertyValues.add("localTime", "12:00 PM"); binder.bind(propertyValues); @@ -198,7 +207,7 @@ public void testBindLocalTime() { } @Test - public void testBindLocalTimeWithSpecificStyle() { + void testBindLocalTimeWithSpecificStyle() { DateTimeFormatterRegistrar registrar = new DateTimeFormatterRegistrar(); registrar.setTimeStyle(FormatStyle.MEDIUM); setup(registrar); @@ -210,7 +219,7 @@ public void testBindLocalTimeWithSpecificStyle() { } @Test - public void testBindLocalTimeWithSpecificFormatter() { + void testBindLocalTimeWithSpecificFormatter() { DateTimeFormatterRegistrar registrar = new DateTimeFormatterRegistrar(); registrar.setTimeFormatter(DateTimeFormatter.ofPattern("HHmmss")); setup(registrar); @@ -222,16 +231,16 @@ public void testBindLocalTimeWithSpecificFormatter() { } @Test - public void testBindLocalTimeAnnotated() { + void testBindLocalTimeAnnotated() { MutablePropertyValues propertyValues = new MutablePropertyValues(); - propertyValues.add("localTimeAnnotated", "12:00:00 PM"); + propertyValues.add("styleLocalTime", "12:00:00 PM"); binder.bind(propertyValues); assertThat(binder.getBindingResult().getErrorCount()).isEqualTo(0); - assertThat(binder.getBindingResult().getFieldValue("localTimeAnnotated")).isEqualTo("12:00:00 PM"); + assertThat(binder.getBindingResult().getFieldValue("styleLocalTime")).isEqualTo("12:00:00 PM"); } @Test - public void testBindLocalTimeFromJavaUtilCalendar() { + void testBindLocalTimeFromJavaUtilCalendar() { MutablePropertyValues propertyValues = new MutablePropertyValues(); propertyValues.add("localTime", new GregorianCalendar(1970, 0, 0, 12, 0)); binder.bind(propertyValues); @@ -240,7 +249,7 @@ public void testBindLocalTimeFromJavaUtilCalendar() { } @Test - public void testBindLocalDateTime() { + void testBindLocalDateTime() { MutablePropertyValues propertyValues = new MutablePropertyValues(); propertyValues.add("localDateTime", LocalDateTime.of(2009, 10, 31, 12, 0)); binder.bind(propertyValues); @@ -251,18 +260,18 @@ public void testBindLocalDateTime() { } @Test - public void testBindLocalDateTimeAnnotated() { + void testBindLocalDateTimeAnnotated() { MutablePropertyValues propertyValues = new MutablePropertyValues(); - propertyValues.add("localDateTimeAnnotated", LocalDateTime.of(2009, 10, 31, 12, 0)); + propertyValues.add("styleLocalDateTime", LocalDateTime.of(2009, 10, 31, 12, 0)); binder.bind(propertyValues); assertThat(binder.getBindingResult().getErrorCount()).isEqualTo(0); - String value = binder.getBindingResult().getFieldValue("localDateTimeAnnotated").toString(); + String value = binder.getBindingResult().getFieldValue("styleLocalDateTime").toString(); assertThat(value.startsWith("Oct 31, 2009")).isTrue(); assertThat(value.endsWith("12:00:00 PM")).isTrue(); } @Test - public void testBindLocalDateTimeFromJavaUtilCalendar() { + void testBindLocalDateTimeFromJavaUtilCalendar() { MutablePropertyValues propertyValues = new MutablePropertyValues(); propertyValues.add("localDateTime", new GregorianCalendar(2009, 9, 31, 12, 0)); binder.bind(propertyValues); @@ -273,7 +282,7 @@ public void testBindLocalDateTimeFromJavaUtilCalendar() { } @Test - public void testBindDateTimeWithSpecificStyle() { + void testBindDateTimeWithSpecificStyle() { DateTimeFormatterRegistrar registrar = new DateTimeFormatterRegistrar(); registrar.setDateTimeStyle(FormatStyle.MEDIUM); setup(registrar); @@ -287,69 +296,98 @@ public void testBindDateTimeWithSpecificStyle() { } @Test - public void testBindDateTimeAnnotatedPattern() { + void testBindPatternLocalDateTime() { MutablePropertyValues propertyValues = new MutablePropertyValues(); - propertyValues.add("dateTimeAnnotatedPattern", "10/31/09 12:00 PM"); + propertyValues.add("patternLocalDateTime", "10/31/09 12:00 PM"); binder.bind(propertyValues); assertThat(binder.getBindingResult().getErrorCount()).isEqualTo(0); - assertThat(binder.getBindingResult().getFieldValue("dateTimeAnnotatedPattern")).isEqualTo("10/31/09 12:00 PM"); + assertThat(binder.getBindingResult().getFieldValue("patternLocalDateTime")).isEqualTo("10/31/09 12:00 PM"); } @Test - public void testBindDateTimeOverflow() { + void testBindDateTimeOverflow() { MutablePropertyValues propertyValues = new MutablePropertyValues(); - propertyValues.add("dateTimeAnnotatedPattern", "02/29/09 12:00 PM"); + propertyValues.add("patternLocalDateTime", "02/29/09 12:00 PM"); binder.bind(propertyValues); assertThat(binder.getBindingResult().getErrorCount()).isEqualTo(1); } @Test - public void testBindISODate() { + void testBindISODate() { MutablePropertyValues propertyValues = new MutablePropertyValues(); - propertyValues.add("isoDate", "2009-10-31"); + propertyValues.add("isoLocalDate", "2009-10-31"); binder.bind(propertyValues); assertThat(binder.getBindingResult().getErrorCount()).isEqualTo(0); - assertThat(binder.getBindingResult().getFieldValue("isoDate")).isEqualTo("2009-10-31"); + assertThat(binder.getBindingResult().getFieldValue("isoLocalDate")).isEqualTo("2009-10-31"); } @Test - public void testBindISOTime() { + void isoLocalDateWithInvalidFormat() { MutablePropertyValues propertyValues = new MutablePropertyValues(); - propertyValues.add("isoTime", "12:00:00"); + String propertyName = "isoLocalDate"; + propertyValues.add(propertyName, "2009-31-10"); + binder.bind(propertyValues); + BindingResult bindingResult = binder.getBindingResult(); + assertThat(bindingResult.getErrorCount()).isEqualTo(1); + FieldError fieldError = bindingResult.getFieldError(propertyName); + assertThat(fieldError.unwrap(TypeMismatchException.class)) + .hasMessageContaining("for property 'isoLocalDate'") + .hasCauseInstanceOf(ConversionFailedException.class).getCause() + .hasMessageContaining("for value '2009-31-10'") + .hasCauseInstanceOf(IllegalArgumentException.class).getCause() + .hasMessageContaining("Parse attempt failed for value [2009-31-10]") + .hasCauseInstanceOf(DateTimeParseException.class).getCause() + // Unable to parse date time value "2009-31-10" using configuration from + // @org.springframework.format.annotation.DateTimeFormat(pattern=, style=SS, iso=DATE, fallbackPatterns=[]) + .hasMessageContainingAll( + "Unable to parse date time value \"2009-31-10\" using configuration from", + "@org.springframework.format.annotation.DateTimeFormat", + "iso=DATE", "fallbackPatterns=[]") + .hasCauseInstanceOf(DateTimeParseException.class).getCause() + .hasMessageStartingWith("Text '2009-31-10'") + .hasCauseInstanceOf(DateTimeException.class).getCause() + .hasMessageContaining("Invalid value for MonthOfYear (valid values 1 - 12): 31") + .hasNoCause(); + } + + @Test + void testBindISOTime() { + MutablePropertyValues propertyValues = new MutablePropertyValues(); + propertyValues.add("isoLocalTime", "12:00:00"); binder.bind(propertyValues); assertThat(binder.getBindingResult().getErrorCount()).isEqualTo(0); - assertThat(binder.getBindingResult().getFieldValue("isoTime")).isEqualTo("12:00:00"); + assertThat(binder.getBindingResult().getFieldValue("isoLocalTime")).isEqualTo("12:00:00"); } @Test - public void testBindISOTimeWithZone() { + void testBindISOTimeWithZone() { MutablePropertyValues propertyValues = new MutablePropertyValues(); - propertyValues.add("isoTime", "12:00:00.000-05:00"); + propertyValues.add("isoLocalTime", "12:00:00.000-05:00"); binder.bind(propertyValues); assertThat(binder.getBindingResult().getErrorCount()).isEqualTo(0); - assertThat(binder.getBindingResult().getFieldValue("isoTime")).isEqualTo("12:00:00"); + assertThat(binder.getBindingResult().getFieldValue("isoLocalTime")).isEqualTo("12:00:00"); } @Test - public void testBindISODateTime() { + void testBindISODateTime() { MutablePropertyValues propertyValues = new MutablePropertyValues(); - propertyValues.add("isoDateTime", "2009-10-31T12:00:00"); + propertyValues.add("isoLocalDateTime", "2009-10-31T12:00:00"); binder.bind(propertyValues); assertThat(binder.getBindingResult().getErrorCount()).isEqualTo(0); - assertThat(binder.getBindingResult().getFieldValue("isoDateTime")).isEqualTo("2009-10-31T12:00:00"); + assertThat(binder.getBindingResult().getFieldValue("isoLocalDateTime")).isEqualTo("2009-10-31T12:00:00"); } @Test - public void testBindISODateTimeWithZone() { + void testBindISODateTimeWithZone() { MutablePropertyValues propertyValues = new MutablePropertyValues(); - propertyValues.add("isoDateTime", "2009-10-31T12:00:00.000Z"); + propertyValues.add("isoLocalDateTime", "2009-10-31T12:00:00.000Z"); binder.bind(propertyValues); assertThat(binder.getBindingResult().getErrorCount()).isEqualTo(0); - assertThat(binder.getBindingResult().getFieldValue("isoDateTime")).isEqualTo("2009-10-31T12:00:00"); + assertThat(binder.getBindingResult().getFieldValue("isoLocalDateTime")).isEqualTo("2009-10-31T12:00:00"); } @Test - public void testBindInstant() { + void testBindInstant() { MutablePropertyValues propertyValues = new MutablePropertyValues(); propertyValues.add("instant", "2009-10-31T12:00:00.000Z"); binder.bind(propertyValues); @@ -359,7 +397,7 @@ public void testBindInstant() { @Test @SuppressWarnings("deprecation") - public void testBindInstantFromJavaUtilDate() { + void testBindInstantFromJavaUtilDate() { TimeZone defaultZone = TimeZone.getDefault(); TimeZone.setDefault(TimeZone.getTimeZone("GMT")); try { @@ -375,7 +413,7 @@ public void testBindInstantFromJavaUtilDate() { } @Test - public void testBindPeriod() { + void testBindPeriod() { MutablePropertyValues propertyValues = new MutablePropertyValues(); propertyValues.add("period", "P6Y3M1D"); binder.bind(propertyValues); @@ -384,7 +422,7 @@ public void testBindPeriod() { } @Test - public void testBindDuration() { + void testBindDuration() { MutablePropertyValues propertyValues = new MutablePropertyValues(); propertyValues.add("duration", "PT8H6M12.345S"); binder.bind(propertyValues); @@ -393,7 +431,7 @@ public void testBindDuration() { } @Test - public void testBindYear() { + void testBindYear() { MutablePropertyValues propertyValues = new MutablePropertyValues(); propertyValues.add("year", "2007"); binder.bind(propertyValues); @@ -402,7 +440,7 @@ public void testBindYear() { } @Test - public void testBindMonth() { + void testBindMonth() { MutablePropertyValues propertyValues = new MutablePropertyValues(); propertyValues.add("month", "JULY"); binder.bind(propertyValues); @@ -411,7 +449,7 @@ public void testBindMonth() { } @Test - public void testBindMonthInAnyCase() { + void testBindMonthInAnyCase() { MutablePropertyValues propertyValues = new MutablePropertyValues(); propertyValues.add("month", "July"); binder.bind(propertyValues); @@ -420,7 +458,7 @@ public void testBindMonthInAnyCase() { } @Test - public void testBindYearMonth() { + void testBindYearMonth() { MutablePropertyValues propertyValues = new MutablePropertyValues(); propertyValues.add("yearMonth", "2007-12"); binder.bind(propertyValues); @@ -429,7 +467,7 @@ public void testBindYearMonth() { } @Test - public void testBindMonthDay() { + void testBindMonthDay() { MutablePropertyValues propertyValues = new MutablePropertyValues(); propertyValues.add("monthDay", "--12-03"); binder.bind(propertyValues); @@ -437,35 +475,128 @@ public void testBindMonthDay() { assertThat(binder.getBindingResult().getFieldValue("monthDay").toString().equals("--12-03")).isTrue(); } + @Nested + class FallbackPatternTests { + + @ParameterizedTest(name = "input date: {0}") + @ValueSource(strings = {"2021-03-02", "2021.03.02", "20210302", "3/2/21"}) + void styleLocalDate(String propertyValue) { + String propertyName = "styleLocalDateWithFallbackPatterns"; + MutablePropertyValues propertyValues = new MutablePropertyValues(); + propertyValues.add(propertyName, propertyValue); + binder.bind(propertyValues); + BindingResult bindingResult = binder.getBindingResult(); + assertThat(bindingResult.getErrorCount()).isEqualTo(0); + assertThat(bindingResult.getFieldValue(propertyName)).isEqualTo("3/2/21"); + } + + @ParameterizedTest(name = "input date: {0}") + @ValueSource(strings = {"2021-03-02", "2021.03.02", "20210302", "3/2/21"}) + void patternLocalDate(String propertyValue) { + String propertyName = "patternLocalDateWithFallbackPatterns"; + MutablePropertyValues propertyValues = new MutablePropertyValues(); + propertyValues.add(propertyName, propertyValue); + binder.bind(propertyValues); + BindingResult bindingResult = binder.getBindingResult(); + assertThat(bindingResult.getErrorCount()).isEqualTo(0); + assertThat(bindingResult.getFieldValue(propertyName)).isEqualTo("2021-03-02"); + } + + @ParameterizedTest(name = "input date: {0}") + @ValueSource(strings = {"12:00:00 PM", "12:00:00", "12:00"}) + void styleLocalTime(String propertyValue) { + String propertyName = "styleLocalTimeWithFallbackPatterns"; + MutablePropertyValues propertyValues = new MutablePropertyValues(); + propertyValues.add(propertyName, propertyValue); + binder.bind(propertyValues); + BindingResult bindingResult = binder.getBindingResult(); + assertThat(bindingResult.getErrorCount()).isEqualTo(0); + assertThat(bindingResult.getFieldValue(propertyName)).isEqualTo("12:00:00 PM"); + } + + @ParameterizedTest(name = "input date: {0}") + @ValueSource(strings = {"2021-03-02T12:00:00", "2021-03-02 12:00:00", "3/2/21 12:00"}) + void isoLocalDateTime(String propertyValue) { + String propertyName = "isoLocalDateTimeWithFallbackPatterns"; + MutablePropertyValues propertyValues = new MutablePropertyValues(); + propertyValues.add(propertyName, propertyValue); + binder.bind(propertyValues); + BindingResult bindingResult = binder.getBindingResult(); + assertThat(bindingResult.getErrorCount()).isEqualTo(0); + assertThat(bindingResult.getFieldValue(propertyName)).isEqualTo("2021-03-02T12:00:00"); + } + + @Test + void patternLocalDateWithUnsupportedPattern() { + String propertyValue = "210302"; + String propertyName = "patternLocalDateWithFallbackPatterns"; + MutablePropertyValues propertyValues = new MutablePropertyValues(); + propertyValues.add(propertyName, propertyValue); + binder.bind(propertyValues); + BindingResult bindingResult = binder.getBindingResult(); + assertThat(bindingResult.getErrorCount()).isEqualTo(1); + FieldError fieldError = bindingResult.getFieldError(propertyName); + assertThat(fieldError.unwrap(TypeMismatchException.class)) + .hasMessageContaining("for property 'patternLocalDateWithFallbackPatterns'") + .hasCauseInstanceOf(ConversionFailedException.class).getCause() + .hasMessageContaining("for value '210302'") + .hasCauseInstanceOf(IllegalArgumentException.class).getCause() + .hasMessageContaining("Parse attempt failed for value [210302]") + .hasCauseInstanceOf(DateTimeParseException.class).getCause() + // Unable to parse date time value "210302" using configuration from + // @org.springframework.format.annotation.DateTimeFormat( + // pattern=yyyy-MM-dd, style=SS, iso=NONE, fallbackPatterns=[M/d/yy, yyyyMMdd, yyyy.MM.dd]) + .hasMessageContainingAll( + "Unable to parse date time value \"210302\" using configuration from", + "@org.springframework.format.annotation.DateTimeFormat", + "yyyy-MM-dd", "M/d/yy", "yyyyMMdd", "yyyy.MM.dd") + .hasCauseInstanceOf(DateTimeParseException.class).getCause() + .hasMessageStartingWith("Text '210302'") + .hasNoCause(); + } + } + public static class DateTimeBean { private LocalDate localDate; @DateTimeFormat(style = "M-") - private LocalDate localDateAnnotated; + private LocalDate styleLocalDate; + + @DateTimeFormat(style = "S-", fallbackPatterns = { "yyyy-MM-dd", "yyyyMMdd", "yyyy.MM.dd" }) + private LocalDate styleLocalDateWithFallbackPatterns; + + @DateTimeFormat(pattern = "yyyy-MM-dd", fallbackPatterns = { "M/d/yy", "yyyyMMdd", "yyyy.MM.dd" }) + private LocalDate patternLocalDateWithFallbackPatterns; private LocalTime localTime; @DateTimeFormat(style = "-M") - private LocalTime localTimeAnnotated; + private LocalTime styleLocalTime; + + @DateTimeFormat(style = "-M", fallbackPatterns = { "HH:mm:ss", "HH:mm"}) + private LocalTime styleLocalTimeWithFallbackPatterns; private LocalDateTime localDateTime; @DateTimeFormat(style = "MM") - private LocalDateTime localDateTimeAnnotated; + private LocalDateTime styleLocalDateTime; @DateTimeFormat(pattern = "M/d/yy h:mm a") - private LocalDateTime dateTimeAnnotatedPattern; + private LocalDateTime patternLocalDateTime; @DateTimeFormat(iso = ISO.DATE) - private LocalDate isoDate; + private LocalDate isoLocalDate; @DateTimeFormat(iso = ISO.TIME) - private LocalTime isoTime; + private LocalTime isoLocalTime; @DateTimeFormat(iso = ISO.DATE_TIME) - private LocalDateTime isoDateTime; + private LocalDateTime isoLocalDateTime; + + @DateTimeFormat(iso = ISO.DATE_TIME, fallbackPatterns = { "yyyy-MM-dd HH:mm:ss", "M/d/yy HH:mm"}) + private LocalDateTime isoLocalDateTimeWithFallbackPatterns; private Instant instant; @@ -483,88 +614,120 @@ public static class DateTimeBean { private final List children = new ArrayList<>(); + public LocalDate getLocalDate() { - return localDate; + return this.localDate; } public void setLocalDate(LocalDate localDate) { this.localDate = localDate; } - public LocalDate getLocalDateAnnotated() { - return localDateAnnotated; + public LocalDate getStyleLocalDate() { + return this.styleLocalDate; + } + + public void setStyleLocalDate(LocalDate styleLocalDate) { + this.styleLocalDate = styleLocalDate; } - public void setLocalDateAnnotated(LocalDate localDateAnnotated) { - this.localDateAnnotated = localDateAnnotated; + public LocalDate getStyleLocalDateWithFallbackPatterns() { + return this.styleLocalDateWithFallbackPatterns; + } + + public void setStyleLocalDateWithFallbackPatterns(LocalDate styleLocalDateWithFallbackPatterns) { + this.styleLocalDateWithFallbackPatterns = styleLocalDateWithFallbackPatterns; + } + public LocalDate getPatternLocalDateWithFallbackPatterns() { + return this.patternLocalDateWithFallbackPatterns; + } + + public void setPatternLocalDateWithFallbackPatterns(LocalDate patternLocalDateWithFallbackPatterns) { + this.patternLocalDateWithFallbackPatterns = patternLocalDateWithFallbackPatterns; } public LocalTime getLocalTime() { - return localTime; + return this.localTime; } public void setLocalTime(LocalTime localTime) { this.localTime = localTime; } - public LocalTime getLocalTimeAnnotated() { - return localTimeAnnotated; + public LocalTime getStyleLocalTime() { + return this.styleLocalTime; } - public void setLocalTimeAnnotated(LocalTime localTimeAnnotated) { - this.localTimeAnnotated = localTimeAnnotated; + public void setStyleLocalTime(LocalTime styleLocalTime) { + this.styleLocalTime = styleLocalTime; + } + + public LocalTime getStyleLocalTimeWithFallbackPatterns() { + return this.styleLocalTimeWithFallbackPatterns; + } + + public void setStyleLocalTimeWithFallbackPatterns(LocalTime styleLocalTimeWithFallbackPatterns) { + this.styleLocalTimeWithFallbackPatterns = styleLocalTimeWithFallbackPatterns; } public LocalDateTime getLocalDateTime() { - return localDateTime; + return this.localDateTime; } public void setLocalDateTime(LocalDateTime localDateTime) { this.localDateTime = localDateTime; } - public LocalDateTime getLocalDateTimeAnnotated() { - return localDateTimeAnnotated; + public LocalDateTime getStyleLocalDateTime() { + return this.styleLocalDateTime; + } + + public void setStyleLocalDateTime(LocalDateTime styleLocalDateTime) { + this.styleLocalDateTime = styleLocalDateTime; + } + + public LocalDateTime getPatternLocalDateTime() { + return this.patternLocalDateTime; } - public void setLocalDateTimeAnnotated(LocalDateTime localDateTimeAnnotated) { - this.localDateTimeAnnotated = localDateTimeAnnotated; + public void setPatternLocalDateTime(LocalDateTime patternLocalDateTime) { + this.patternLocalDateTime = patternLocalDateTime; } - public LocalDateTime getDateTimeAnnotatedPattern() { - return dateTimeAnnotatedPattern; + public LocalDate getIsoLocalDate() { + return this.isoLocalDate; } - public void setDateTimeAnnotatedPattern(LocalDateTime dateTimeAnnotatedPattern) { - this.dateTimeAnnotatedPattern = dateTimeAnnotatedPattern; + public void setIsoLocalDate(LocalDate isoLocalDate) { + this.isoLocalDate = isoLocalDate; } - public LocalDate getIsoDate() { - return isoDate; + public LocalTime getIsoLocalTime() { + return this.isoLocalTime; } - public void setIsoDate(LocalDate isoDate) { - this.isoDate = isoDate; + public void setIsoLocalTime(LocalTime isoLocalTime) { + this.isoLocalTime = isoLocalTime; } - public LocalTime getIsoTime() { - return isoTime; + public LocalDateTime getIsoLocalDateTime() { + return this.isoLocalDateTime; } - public void setIsoTime(LocalTime isoTime) { - this.isoTime = isoTime; + public void setIsoLocalDateTime(LocalDateTime isoLocalDateTime) { + this.isoLocalDateTime = isoLocalDateTime; } - public LocalDateTime getIsoDateTime() { - return isoDateTime; + public LocalDateTime getIsoLocalDateTimeWithFallbackPatterns() { + return this.isoLocalDateTimeWithFallbackPatterns; } - public void setIsoDateTime(LocalDateTime isoDateTime) { - this.isoDateTime = isoDateTime; + public void setIsoLocalDateTimeWithFallbackPatterns(LocalDateTime isoLocalDateTimeWithFallbackPatterns) { + this.isoLocalDateTimeWithFallbackPatterns = isoLocalDateTimeWithFallbackPatterns; } public Instant getInstant() { - return instant; + return this.instant; } public void setInstant(Instant instant) { @@ -572,7 +735,7 @@ public void setInstant(Instant instant) { } public Period getPeriod() { - return period; + return this.period; } public void setPeriod(Period period) { @@ -580,7 +743,7 @@ public void setPeriod(Period period) { } public Duration getDuration() { - return duration; + return this.duration; } public void setDuration(Duration duration) { @@ -588,7 +751,7 @@ public void setDuration(Duration duration) { } public Year getYear() { - return year; + return this.year; } public void setYear(Year year) { @@ -596,7 +759,7 @@ public void setYear(Year year) { } public Month getMonth() { - return month; + return this.month; } public void setMonth(Month month) { @@ -604,7 +767,7 @@ public void setMonth(Month month) { } public YearMonth getYearMonth() { - return yearMonth; + return this.yearMonth; } public void setYearMonth(YearMonth yearMonth) { @@ -612,7 +775,7 @@ public void setYearMonth(YearMonth yearMonth) { } public MonthDay getMonthDay() { - return monthDay; + return this.monthDay; } public void setMonthDay(MonthDay monthDay) { @@ -620,7 +783,7 @@ public void setMonthDay(MonthDay monthDay) { } public List getChildren() { - return children; + return this.children; } } diff --git a/spring-context/src/test/java/org/springframework/scheduling/support/BitsCronFieldTests.java b/spring-context/src/test/java/org/springframework/scheduling/support/BitsCronFieldTests.java index 906e98ea5202..37d6b30d4fd3 100644 --- a/spring-context/src/test/java/org/springframework/scheduling/support/BitsCronFieldTests.java +++ b/spring-context/src/test/java/org/springframework/scheduling/support/BitsCronFieldTests.java @@ -48,6 +48,8 @@ void parse() { assertThat(BitsCronField.parseMonth("1")).has(set(1)).has(clearRange(2, 12)); assertThat(BitsCronField.parseDaysOfWeek("0")).has(set(7, 7)).has(clearRange(0, 6)); + + assertThat(BitsCronField.parseDaysOfWeek("7-5")).has(clear(0)).has(setRange(1, 5)).has(clear(6)).has(set(7)); } @Test diff --git a/spring-context/src/test/java/org/springframework/scheduling/support/CronExpressionTests.java b/spring-context/src/test/java/org/springframework/scheduling/support/CronExpressionTests.java index c7e1bb5c2b61..aea49716d89e 100644 --- a/spring-context/src/test/java/org/springframework/scheduling/support/CronExpressionTests.java +++ b/spring-context/src/test/java/org/springframework/scheduling/support/CronExpressionTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -1242,5 +1242,42 @@ void quartzLastFridayOfTheMonthEveryHour() { assertThat(actual).isEqualTo(expected); } + @Test + public void sundayToFriday() { + CronExpression expression = CronExpression.parse("0 0 0 ? * SUN-FRI"); + + LocalDateTime last = LocalDateTime.of(2021, 2, 25, 15, 0); + LocalDateTime expected = LocalDateTime.of(2021, 2, 26, 0, 0); + LocalDateTime actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual.getDayOfWeek()).isEqualTo(FRIDAY); + + last = actual; + expected = LocalDateTime.of(2021, 2, 28, 0, 0); + actual = expression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + assertThat(actual.getDayOfWeek()).isEqualTo(SUNDAY); + } + + @Test + public void daylightSaving() { + CronExpression cronExpression = CronExpression.parse("0 0 9 * * *"); + + ZonedDateTime last = ZonedDateTime.parse("2021-03-27T09:00:00+01:00[Europe/Amsterdam]"); + ZonedDateTime expected = ZonedDateTime.parse("2021-03-28T09:00:00+02:00[Europe/Amsterdam]"); + ZonedDateTime actual = cronExpression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + + last = ZonedDateTime.parse("2021-10-30T09:00:00+02:00[Europe/Amsterdam]"); + expected = ZonedDateTime.parse("2021-10-31T09:00:00+01:00[Europe/Amsterdam]"); + actual = cronExpression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); + } + + } diff --git a/spring-core/spring-core.gradle b/spring-core/spring-core.gradle index 9672530e8325..a4166705d32d 100644 --- a/spring-core/spring-core.gradle +++ b/spring-core/spring-core.gradle @@ -8,7 +8,7 @@ apply plugin: "kotlin" // cglib itself depends on asm and is therefore further transformed by the ShadowJar task to // depend on org.springframework.asm; this avoids including two different copies of asm. def cglibVersion = "3.3.0" -def objenesisVersion = "3.1" +def objenesisVersion = "3.2" configurations { cglib @@ -70,8 +70,8 @@ dependencies { jar { reproducibleFileOrder = true - preserveFileTimestamps = false // maybe not necessary here, but good for reproducibility - manifest.attributes["Dependencies"] = "jdk.unsupported" // JBoss modules + preserveFileTimestamps = false // maybe not necessary here, but good for reproducibility + manifest.attributes["Dependencies"] = "jdk.unsupported" // for WildFly (-> Objenesis 3.2) // Inline repackaged cglib classes directly into spring-core jar dependsOn cglibRepackJar diff --git a/spring-core/src/main/java/org/springframework/core/BridgeMethodResolver.java b/spring-core/src/main/java/org/springframework/core/BridgeMethodResolver.java index 1615469dd154..7ea7e9cb6e93 100644 --- a/spring-core/src/main/java/org/springframework/core/BridgeMethodResolver.java +++ b/spring-core/src/main/java/org/springframework/core/BridgeMethodResolver.java @@ -163,7 +163,7 @@ private static boolean isResolvedTypeMatch(Method genericMethod, Method candidat } } // A non-array type: compare the type itself. - if (!candidateParameter.equals(genericParameter.toClass())) { + if (!ClassUtils.resolvePrimitiveIfNecessary(candidateParameter).equals(ClassUtils.resolvePrimitiveIfNecessary(genericParameter.toClass()))) { return false; } } diff --git a/spring-core/src/main/java/org/springframework/core/SmartClassLoader.java b/spring-core/src/main/java/org/springframework/core/SmartClassLoader.java index fe7d3637090e..e6a39c149ec4 100644 --- a/spring-core/src/main/java/org/springframework/core/SmartClassLoader.java +++ b/spring-core/src/main/java/org/springframework/core/SmartClassLoader.java @@ -47,6 +47,28 @@ default boolean isClassReloadable(Class clazz) { return false; } + /** + * Return the original ClassLoader for this SmartClassLoader, or potentially + * the present loader itself if it is self-sufficient. + *

    The default implementation returns the local ClassLoader reference as-is. + * In case of a reloadable or other selectively overriding ClassLoader which + * commonly deals with unaffected classes from a base application class loader, + * this should get implemented to return the original ClassLoader that the + * present loader got derived from (e.g. through {@code return getParent();}). + *

    This gets specifically used in Spring's AOP framework to determine the + * class loader for a specific proxy in case the target class has not been + * defined in the present class loader. In case of a reloadable class loader, + * we prefer the base application class loader for proxying general classes + * not defined in the reloadable class loader itself. + * @return the original ClassLoader (the same reference by default) + * @since 5.3.5 + * @see ClassLoader#getParent() + * @see org.springframework.aop.framework.autoproxy.AbstractAutoProxyCreator + */ + default ClassLoader getOriginalClassLoader() { + return (ClassLoader) this; + } + /** * Define a custom class (typically a CGLIB proxy class) in this class loader. *

    This is a public equivalent of the protected diff --git a/spring-core/src/main/java/org/springframework/core/convert/TypeDescriptor.java b/spring-core/src/main/java/org/springframework/core/convert/TypeDescriptor.java index 3e2aee829558..de43740b852a 100644 --- a/spring-core/src/main/java/org/springframework/core/convert/TypeDescriptor.java +++ b/spring-core/src/main/java/org/springframework/core/convert/TypeDescriptor.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -515,7 +515,7 @@ public String toString() { for (Annotation ann : getAnnotations()) { builder.append("@").append(ann.annotationType().getName()).append(' '); } - builder.append(getResolvableType().toString()); + builder.append(getResolvableType()); return builder.toString(); } diff --git a/spring-core/src/main/java/org/springframework/core/env/StandardEnvironment.java b/spring-core/src/main/java/org/springframework/core/env/StandardEnvironment.java index 5b6c60ada239..b228de56bb2c 100644 --- a/spring-core/src/main/java/org/springframework/core/env/StandardEnvironment.java +++ b/spring-core/src/main/java/org/springframework/core/env/StandardEnvironment.java @@ -60,9 +60,17 @@ public class StandardEnvironment extends AbstractEnvironment { public static final String SYSTEM_PROPERTIES_PROPERTY_SOURCE_NAME = "systemProperties"; + /** + * Create a new {@code StandardEnvironment} instance. + */ public StandardEnvironment() { } + /** + * Create a new {@code StandardEnvironment} instance with a specific {@link MutablePropertySources} instance. + * @param propertySources property sources to use + * @since 5.3.4 + */ protected StandardEnvironment(MutablePropertySources propertySources) { super(propertySources); } diff --git a/spring-core/src/main/java/org/springframework/core/io/ClassPathResource.java b/spring-core/src/main/java/org/springframework/core/io/ClassPathResource.java index c618dfddbd61..6374f2768b9d 100644 --- a/spring-core/src/main/java/org/springframework/core/io/ClassPathResource.java +++ b/spring-core/src/main/java/org/springframework/core/io/ClassPathResource.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -148,14 +148,21 @@ public boolean exists() { */ @Nullable protected URL resolveURL() { - if (this.clazz != null) { - return this.clazz.getResource(this.path); - } - else if (this.classLoader != null) { - return this.classLoader.getResource(this.path); + try { + if (this.clazz != null) { + return this.clazz.getResource(this.path); + } + else if (this.classLoader != null) { + return this.classLoader.getResource(this.path); + } + else { + return ClassLoader.getSystemResource(this.path); + } } - else { - return ClassLoader.getSystemResource(this.path); + catch (IllegalArgumentException ex) { + // Should not happen according to the JDK's contract: + // see https://github.com/openjdk/jdk/pull/2662 + return null; } } diff --git a/spring-core/src/main/java/org/springframework/core/log/LogDelegateFactory.java b/spring-core/src/main/java/org/springframework/core/log/LogDelegateFactory.java index 6097a7f53e4f..a672f2f6a396 100644 --- a/spring-core/src/main/java/org/springframework/core/log/LogDelegateFactory.java +++ b/spring-core/src/main/java/org/springframework/core/log/LogDelegateFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -63,15 +63,28 @@ public static Log getCompositeLog(Log primaryLogger, Log secondaryLogger, Log... } /** - * Create a "hidden" logger whose name is intentionally prefixed with "_" - * because its output is either too verbose or otherwise deemed as optional - * or unnecessary to see at any log level by default under the normal package - * based log hierarchy. + * Create a "hidden" logger with a category name prefixed with "_", thus + * precluding it from being enabled together with other log categories from + * the same package. This is useful for specialized output that is either + * too verbose or otherwise optional or unnecessary to see all the time. * @param clazz the class for which to create a logger - * @return a logger for the hidden category ("_" + fully-qualified class name) + * @return a Log with the category {@code "_" + fully-qualified class name} */ public static Log getHiddenLog(Class clazz) { - return LogFactory.getLog("_" + clazz.getName()); + return getHiddenLog(clazz.getName()); + } + + /** + * Create a "hidden" logger with a category name prefixed with "_", thus + * precluding it from being enabled together with other log categories from + * the same package. This is useful for specialized output that is either + * too verbose or otherwise optional or unnecessary to see all the time. + * @param category the log category to use + * @return a Log with the category {@code "_" + category} + * @since 5.3.5 + */ + public static Log getHiddenLog(String category) { + return LogFactory.getLog("_" + category); } } diff --git a/spring-core/src/main/java/org/springframework/objenesis/package-info.java b/spring-core/src/main/java/org/springframework/objenesis/package-info.java index 8decb0e12258..017681c78f09 100644 --- a/spring-core/src/main/java/org/springframework/objenesis/package-info.java +++ b/spring-core/src/main/java/org/springframework/objenesis/package-info.java @@ -1,6 +1,6 @@ /** * Spring's repackaging of - * Objenesis 3.0 + * Objenesis 3.2 * (with SpringObjenesis entry point; for internal use only). * *

    This repackaging technique avoids any potential conflicts with diff --git a/spring-core/src/main/java/org/springframework/util/ReflectionUtils.java b/spring-core/src/main/java/org/springframework/util/ReflectionUtils.java index 6b457daaa360..2177481f0d30 100644 --- a/spring-core/src/main/java/org/springframework/util/ReflectionUtils.java +++ b/spring-core/src/main/java/org/springframework/util/ReflectionUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -384,7 +384,7 @@ else if (clazz.isInterface()) { * @throws IllegalStateException if introspection fails */ public static Method[] getAllDeclaredMethods(Class leafClass) { - final List methods = new ArrayList<>(32); + final List methods = new ArrayList<>(20); doWithMethods(leafClass, methods::add); return methods.toArray(EMPTY_METHOD_ARRAY); } @@ -410,7 +410,7 @@ public static Method[] getUniqueDeclaredMethods(Class leafClass) { * @since 5.2 */ public static Method[] getUniqueDeclaredMethods(Class leafClass, @Nullable MethodFilter mf) { - final List methods = new ArrayList<>(32); + final List methods = new ArrayList<>(20); doWithMethods(leafClass, method -> { boolean knownSignature = false; Method methodBeingOverriddenWithCovariantReturnType = null; @@ -625,6 +625,7 @@ public static Field findField(Class clazz, @Nullable String name, @Nullable C *

    Thrown exceptions are handled via a call to {@link #handleReflectionException(Exception)}. * @param field the field to set * @param target the target object on which to set the field + * (or {@code null} for a static field) * @param value the value to set (may be {@code null}) */ public static void setField(Field field, @Nullable Object target, @Nullable Object value) { @@ -644,6 +645,7 @@ public static void setField(Field field, @Nullable Object target, @Nullable Obje *

    Thrown exceptions are handled via a call to {@link #handleReflectionException(Exception)}. * @param field the field to get * @param target the target object from which to get the field + * (or {@code null} for a static field) * @return the field's current value */ @Nullable diff --git a/spring-core/src/main/java/org/springframework/util/StringUtils.java b/spring-core/src/main/java/org/springframework/util/StringUtils.java index 6d6d577cc511..670edea3a4f2 100644 --- a/spring-core/src/main/java/org/springframework/util/StringUtils.java +++ b/spring-core/src/main/java/org/springframework/util/StringUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -1342,8 +1342,8 @@ public static String arrayToDelimitedString(@Nullable Object[] arr, String delim } StringJoiner sj = new StringJoiner(delim); - for (Object o : arr) { - sj.add(String.valueOf(o)); + for (Object elem : arr) { + sj.add(String.valueOf(elem)); } return sj.toString(); } diff --git a/spring-core/src/test/java/org/springframework/core/env/CustomEnvironmentTests.java b/spring-core/src/test/java/org/springframework/core/env/CustomEnvironmentTests.java index 7f35a190c8f1..cc6098f52b80 100644 --- a/spring-core/src/test/java/org/springframework/core/env/CustomEnvironmentTests.java +++ b/spring-core/src/test/java/org/springframework/core/env/CustomEnvironmentTests.java @@ -108,7 +108,7 @@ protected Set getReservedDefaultProfiles() { } @Test - public void withNoProfileProperties() { + void withNoProfileProperties() { ConfigurableEnvironment env = new AbstractEnvironment() { @Override @Nullable @@ -131,7 +131,7 @@ protected String doGetDefaultProfilesProperty() { } @Test - public void withCustomMutablePropertySources() { + void withCustomMutablePropertySources() { class CustomMutablePropertySources extends MutablePropertySources {} MutablePropertySources propertySources = new CustomMutablePropertySources(); ConfigurableEnvironment env = new AbstractEnvironment(propertySources) {}; diff --git a/spring-core/src/test/java/org/springframework/core/env/JOptCommandLinePropertySourceTests.java b/spring-core/src/test/java/org/springframework/core/env/JOptCommandLinePropertySourceTests.java index 0ebb4e99e8ba..8a11ab6ccebe 100644 --- a/spring-core/src/test/java/org/springframework/core/env/JOptCommandLinePropertySourceTests.java +++ b/spring-core/src/test/java/org/springframework/core/env/JOptCommandLinePropertySourceTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -181,7 +181,7 @@ void withRequiredArg_ofTypeEnum() { } public enum OptionEnum { - VAL_1; + VAL_1 } } diff --git a/spring-core/src/test/kotlin/org/springframework/core/KotlinBridgeMethodResolverTests.kt b/spring-core/src/test/kotlin/org/springframework/core/KotlinBridgeMethodResolverTests.kt new file mode 100644 index 000000000000..54db15dd45ae --- /dev/null +++ b/spring-core/src/test/kotlin/org/springframework/core/KotlinBridgeMethodResolverTests.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS 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 + +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.Test + +class KotlinBridgeMethodResolverTests { + + @Test + fun findBridgedMethod() { + val unbridged = GenericRepository::class.java.getDeclaredMethod("delete", Int::class.java) + val bridged = GenericRepository::class.java.getDeclaredMethod("delete", Any::class.java) + Assertions.assertThat(unbridged.isBridge).isFalse + Assertions.assertThat(bridged.isBridge).isTrue + + Assertions.assertThat(BridgeMethodResolver.findBridgedMethod(unbridged)).`as`("Unbridged method not returned directly").isEqualTo(unbridged) + Assertions.assertThat(BridgeMethodResolver.findBridgedMethod(bridged)).`as`("Incorrect bridged method returned").isEqualTo(unbridged) + } + + @Test + fun findBridgedMethodWithArrays() { + val unbridged = GenericRepository::class.java.getDeclaredMethod("delete", Array::class.java) + val bridged = GenericRepository::class.java.getDeclaredMethod("delete", Array::class.java) + Assertions.assertThat(unbridged.isBridge).isFalse + Assertions.assertThat(bridged.isBridge).isTrue + + Assertions.assertThat(BridgeMethodResolver.findBridgedMethod(unbridged)).`as`("Unbridged method not returned directly").isEqualTo(unbridged) + Assertions.assertThat(BridgeMethodResolver.findBridgedMethod(bridged)).`as`("Incorrect bridged method returned").isEqualTo(unbridged) + } +} + +interface GenericInterface { + fun delete(id: ID) + fun delete(ids: Array) +} + +abstract class AbstractGenericClass : GenericInterface { + + override fun delete(id: ID) { + } + + override fun delete(ids: Array) { + } +} + +class GenericRepository : AbstractGenericClass() { + + override fun delete(id: Int) { + error("gotcha") + } + + override fun delete(ids: Array) { + error("gotcha") + } +} + diff --git a/spring-core/src/testFixtures/java/org/springframework/core/testfixture/io/buffer/AbstractDataBufferAllocatingTests.java b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/io/buffer/AbstractDataBufferAllocatingTests.java index ffe4c5d107ce..b76d13fe2406 100644 --- a/spring-core/src/testFixtures/java/org/springframework/core/testfixture/io/buffer/AbstractDataBufferAllocatingTests.java +++ b/spring-core/src/testFixtures/java/org/springframework/core/testfixture/io/buffer/AbstractDataBufferAllocatingTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,6 +20,7 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.Instant; @@ -89,8 +90,12 @@ protected void release(DataBuffer... buffers) { } protected Consumer stringConsumer(String expected) { + return stringConsumer(expected, UTF_8); + } + + protected Consumer stringConsumer(String expected, Charset charset) { return dataBuffer -> { - String value = dataBuffer.toString(UTF_8); + String value = dataBuffer.toString(charset); DataBufferUtils.release(dataBuffer); assertThat(value).isEqualTo(expected); }; diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/standard/SpelCompiler.java b/spring-expression/src/main/java/org/springframework/expression/spel/standard/SpelCompiler.java index 82d17e72bfb0..f2a225952a0d 100644 --- a/spring-expression/src/main/java/org/springframework/expression/spel/standard/SpelCompiler.java +++ b/spring-expression/src/main/java/org/springframework/expression/spel/standard/SpelCompiler.java @@ -136,6 +136,7 @@ private int getNextSuffix() { private Class createExpressionClass(SpelNodeImpl expressionToCompile) { // Create class outline 'spel/ExNNN extends org.springframework.expression.spel.CompiledExpression' String className = "spel/Ex" + getNextSuffix(); + String evaluationContextClass = "org/springframework/expression/EvaluationContext"; ClassWriter cw = new ExpressionClassWriter(); cw.visit(V1_8, ACC_PUBLIC, className, null, "org/springframework/expression/spel/CompiledExpression", null); @@ -151,7 +152,7 @@ private Class createExpressionClass(SpelNodeImpl e // Create getValue() method mv = cw.visitMethod(ACC_PUBLIC, "getValue", - "(Ljava/lang/Object;Lorg/springframework/expression/EvaluationContext;)Ljava/lang/Object;", null, + "(Ljava/lang/Object;L" + evaluationContextClass + ";)Ljava/lang/Object;", null, new String[] {"org/springframework/expression/EvaluationException"}); mv.visitCode(); diff --git a/spring-jdbc/src/main/java/org/springframework/jdbc/core/BeanPropertyRowMapper.java b/spring-jdbc/src/main/java/org/springframework/jdbc/core/BeanPropertyRowMapper.java index 4beec4a505a6..397c21f67883 100644 --- a/spring-jdbc/src/main/java/org/springframework/jdbc/core/BeanPropertyRowMapper.java +++ b/spring-jdbc/src/main/java/org/springframework/jdbc/core/BeanPropertyRowMapper.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -75,6 +75,7 @@ * @author Juergen Hoeller * @since 2.5 * @param the result type + * @see DataClassRowMapper */ public class BeanPropertyRowMapper implements RowMapper { diff --git a/spring-jms/spring-jms.gradle b/spring-jms/spring-jms.gradle index 784aead7da80..a447c97a8084 100644 --- a/spring-jms/spring-jms.gradle +++ b/spring-jms/spring-jms.gradle @@ -14,5 +14,6 @@ dependencies { optional("com.fasterxml.jackson.core:jackson-databind") testCompile(testFixtures(project(":spring-beans"))) testCompile(testFixtures(project(":spring-tx"))) + testCompile("org.apache.activemq:activemq-broker") testImplementation("javax.jms:javax.jms-api") } diff --git a/spring-jms/src/main/java/org/springframework/jms/config/AbstractJmsListenerEndpoint.java b/spring-jms/src/main/java/org/springframework/jms/config/AbstractJmsListenerEndpoint.java index 52d584a21bdc..242e7344a8d5 100644 --- a/spring-jms/src/main/java/org/springframework/jms/config/AbstractJmsListenerEndpoint.java +++ b/spring-jms/src/main/java/org/springframework/jms/config/AbstractJmsListenerEndpoint.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,6 +23,7 @@ import org.springframework.jms.listener.endpoint.JmsActivationSpecConfig; import org.springframework.jms.listener.endpoint.JmsMessageEndpointManager; import org.springframework.lang.Nullable; +import org.springframework.util.StringUtils; /** * Base model for a JMS listener endpoint. @@ -50,10 +51,16 @@ public abstract class AbstractJmsListenerEndpoint implements JmsListenerEndpoint private String concurrency; + /** + * Set a custom id for this endpoint. + */ public void setId(String id) { this.id = id; } + /** + * Return the id of this endpoint (possibly generated). + */ @Override public String getId() { return this.id; @@ -136,6 +143,9 @@ public void setupListenerContainer(MessageListenerContainer listenerContainer) { } private void setupJmsListenerContainer(AbstractMessageListenerContainer listenerContainer) { + if (StringUtils.hasText(getId())) { + listenerContainer.setBeanName(getId()); + } if (getDestination() != null) { listenerContainer.setDestinationName(getDestination()); } diff --git a/spring-jms/src/main/java/org/springframework/jms/listener/DefaultMessageListenerContainer.java b/spring-jms/src/main/java/org/springframework/jms/listener/DefaultMessageListenerContainer.java index eb6c46aa8d62..856be8c914da 100644 --- a/spring-jms/src/main/java/org/springframework/jms/listener/DefaultMessageListenerContainer.java +++ b/spring-jms/src/main/java/org/springframework/jms/listener/DefaultMessageListenerContainer.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -192,6 +192,8 @@ public class DefaultMessageListenerContainer extends AbstractPollingMessageListe private int idleTaskExecutionLimit = 1; + private int idleReceivesPerTaskLimit = Integer.MIN_VALUE; + private final Set scheduledInvokers = new HashSet<>(); private int activeInvokerCount = 0; @@ -508,6 +510,49 @@ public final int getIdleTaskExecutionLimit() { } } + /** + * Marks the consumer as 'idle' after the specified number of idle receives + * have been reached. An idle receive is counted from the moment a null message + * is returned by the receiver after the potential {@link #setReceiveTimeout} + * elapsed. This gives the opportunity to check if the idle task count exceeds + * {@link #setIdleTaskExecutionLimit} and based on that decide if the task needs + * to be re-scheduled or not, saving resources that would otherwise be held. + *

    This setting differs from {@link #setMaxMessagesPerTask} where the task is + * released and re-scheduled after this limit is reached, no matter if the received + * messages were null or non-null messages. This setting alone can be inflexible + * if one desires to have a large enough batch for each task but requires a + * quick(er) release from the moment there are no more messages to process. + *

    This setting differs from {@link #setIdleTaskExecutionLimit} where this limit + * decides after how many iterations of being marked as idle, a task is released. + *

    For example: If {@link #setMaxMessagesPerTask} is set to '500' and + * {@code #setIdleReceivesPerTaskLimit} is set to '60' and {@link #setReceiveTimeout} + * is set to '1000' and {@link #setIdleTaskExecutionLimit} is set to '1', then 500 + * messages per task would be processed unless there is a subsequent number of 60 + * idle messages received, the task would be marked as idle and released. This also + * means that after the last message was processed, the task would be released after + * 60 seconds as long as no new messages appear. + * @since 5.3.5 + * @see #setMaxMessagesPerTask + * @see #setReceiveTimeout + */ + public void setIdleReceivesPerTaskLimit(int idleReceivesPerTaskLimit) { + Assert.isTrue(idleReceivesPerTaskLimit != 0, "'idleReceivesPerTaskLimit' must not be 0)"); + synchronized (this.lifecycleMonitor) { + this.idleReceivesPerTaskLimit = idleReceivesPerTaskLimit; + } + } + + /** + * Return the maximum number of subsequent null messages to receive in a single task + * before marking the consumer as 'idle'. + * @since 5.3.5 + */ + public int getIdleReceivesPerTaskLimit() { + synchronized (this.lifecycleMonitor) { + return this.idleReceivesPerTaskLimit; + } + } + //------------------------------------------------------------------------- // Implementation of AbstractMessageListenerContainer's template methods @@ -963,11 +1008,8 @@ protected void refreshConnectionUntilSuccessful() { } } if (!applyBackOffTime(execution)) { - StringBuilder msg = new StringBuilder(); - msg.append("Stopping container for destination '") - .append(getDestinationDescription()) - .append("': back-off policy does not allow ").append("for further attempts."); - logger.error(msg.toString()); + logger.error("Stopping container for destination '" + getDestinationDescription() + + "': back-off policy does not allow for further attempts."); stop(); } } @@ -1072,14 +1114,20 @@ public void run() { } boolean messageReceived = false; try { - if (maxMessagesPerTask < 0) { + int messageLimit = maxMessagesPerTask; + int idleLimit = idleReceivesPerTaskLimit; + if (messageLimit < 0 && idleLimit < 0) { messageReceived = executeOngoingLoop(); } else { int messageCount = 0; - while (isRunning() && messageCount < maxMessagesPerTask) { - messageReceived = (invokeListener() || messageReceived); + int idleCount = 0; + while (isRunning() && (messageLimit < 0 || messageCount < messageLimit) && + (idleLimit < 0 || idleCount < idleLimit)) { + boolean currentReceived = invokeListener(); + messageReceived |= currentReceived; messageCount++; + idleCount = (currentReceived ? 0 : idleCount + 1); } } } diff --git a/spring-jms/src/test/java/org/springframework/jms/config/JmsNamespaceHandlerTests.java b/spring-jms/src/test/java/org/springframework/jms/config/JmsNamespaceHandlerTests.java index 8755e944b2e7..43b9a1a6ca58 100644 --- a/spring-jms/src/test/java/org/springframework/jms/config/JmsNamespaceHandlerTests.java +++ b/spring-jms/src/test/java/org/springframework/jms/config/JmsNamespaceHandlerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -69,12 +69,12 @@ public class JmsNamespaceHandlerTests { @BeforeEach - public void setUp() throws Exception { + public void setup() { this.context = new ToolingTestApplicationContext("jmsNamespaceHandlerTests.xml", getClass()); } @AfterEach - public void tearDown() throws Exception { + public void shutdown() { this.context.close(); } @@ -88,11 +88,11 @@ public void testBeansCreated() { assertThat(containers.size()).as("Context should contain 3 JCA endpoint containers").isEqualTo(3); assertThat(context.getBeansOfType(JmsListenerContainerFactory.class)) - .as("Context should contain 3 JmsListenerContainerFactory instances").hasSize(3); + .as("Context should contain 3 JmsListenerContainerFactory instances").hasSize(3); } @Test - public void testContainerConfiguration() throws Exception { + public void testContainerConfiguration() { Map containers = context.getBeansOfType(DefaultMessageListenerContainer.class); ConnectionFactory defaultConnectionFactory = context.getBean(DEFAULT_CONNECTION_FACTORY, ConnectionFactory.class); ConnectionFactory explicitConnectionFactory = context.getBean(EXPLICIT_CONNECTION_FACTORY, ConnectionFactory.class); @@ -114,7 +114,7 @@ else if (container.getConnectionFactory().equals(explicitConnectionFactory)) { } @Test - public void testJcaContainerConfiguration() throws Exception { + public void testJcaContainerConfiguration() { Map containers = context.getBeansOfType(JmsMessageEndpointManager.class); assertThat(containers.containsKey("listener3")).as("listener3 not found").isTrue(); diff --git a/spring-jms/src/test/java/org/springframework/jms/listener/MessageListenerContainerIntegrationTests.java b/spring-jms/src/test/java/org/springframework/jms/listener/MessageListenerContainerIntegrationTests.java new file mode 100644 index 000000000000..306c88394f43 --- /dev/null +++ b/spring-jms/src/test/java/org/springframework/jms/listener/MessageListenerContainerIntegrationTests.java @@ -0,0 +1,119 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.jms.listener; + +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import javax.jms.JMSException; +import javax.jms.Session; +import javax.jms.TextMessage; + +import org.apache.activemq.ActiveMQConnectionFactory; +import org.junit.jupiter.api.Test; + +import org.springframework.jms.core.JmsTemplate; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Juergen Hoeller + * @since 5.3.5 + */ +public class MessageListenerContainerIntegrationTests { + + @Test + public void simpleMessageListenerContainer() throws InterruptedException { + SimpleMessageListenerContainer mlc = new SimpleMessageListenerContainer(); + + testMessageListenerContainer(mlc); + } + + @Test + public void defaultMessageListenerContainer() throws InterruptedException { + DefaultMessageListenerContainer mlc = new DefaultMessageListenerContainer(); + + testMessageListenerContainer(mlc); + } + + @Test + public void defaultMessageListenerContainerWithMaxMessagesPerTask() throws InterruptedException { + DefaultMessageListenerContainer mlc = new DefaultMessageListenerContainer(); + mlc.setConcurrentConsumers(1); + mlc.setMaxConcurrentConsumers(2); + mlc.setMaxMessagesPerTask(1); + + testMessageListenerContainer(mlc); + } + + @Test + public void defaultMessageListenerContainerWithIdleReceivesPerTaskLimit() throws InterruptedException { + DefaultMessageListenerContainer mlc = new DefaultMessageListenerContainer(); + mlc.setConcurrentConsumers(1); + mlc.setMaxConcurrentConsumers(2); + mlc.setIdleReceivesPerTaskLimit(1); + + testMessageListenerContainer(mlc); + } + + private void testMessageListenerContainer(AbstractMessageListenerContainer mlc) throws InterruptedException { + ActiveMQConnectionFactory aqcf = new ActiveMQConnectionFactory("vm://localhost?broker.persistent=false"); + TestMessageListener tml = new TestMessageListener(); + + mlc.setConnectionFactory(aqcf); + mlc.setMessageListener(tml); + mlc.setDestinationName("test"); + mlc.afterPropertiesSet(); + mlc.start(); + + JmsTemplate jt = new JmsTemplate(aqcf); + jt.setDefaultDestinationName("test"); + + Set messages = new HashSet<>(); + messages.add("text1"); + messages.add("text2"); + for (String message : messages) { + jt.convertAndSend(message); + } + assertThat(tml.result()).isEqualTo(messages); + + mlc.destroy(); + } + + + private static class TestMessageListener implements SessionAwareMessageListener { + + private final CountDownLatch latch = new CountDownLatch(2); + + private final Set messages = new CopyOnWriteArraySet<>(); + + @Override + public void onMessage(TextMessage message, Session session) throws JMSException { + this.messages.add(message.getText()); + this.latch.countDown(); + } + + public Set result() throws InterruptedException { + assertThat(this.latch.await(5, TimeUnit.SECONDS)).isTrue(); + return this.messages; + } + } + +} diff --git a/spring-jms/src/test/java/org/springframework/jms/listener/SimpleMessageListenerContainerTests.java b/spring-jms/src/test/java/org/springframework/jms/listener/SimpleMessageListenerContainerTests.java index ae9e9c64fb3e..76bd754e2cc5 100644 --- a/spring-jms/src/test/java/org/springframework/jms/listener/SimpleMessageListenerContainerTests.java +++ b/spring-jms/src/test/java/org/springframework/jms/listener/SimpleMessageListenerContainerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -75,8 +75,7 @@ public void testSettingMessageListenerToAnUnsupportedType() { @Test public void testSessionTransactedModeReallyDoesDefaultToFalse() { assertThat(this.container.isPubSubNoLocal()).as("The [pubSubLocal] property of SimpleMessageListenerContainer " + - "must default to false. Change this test (and the " + - "attendant Javadoc) if you have changed the default.").isFalse(); + "must default to false. Change this test (and the attendant javadoc) if you have changed the default.").isFalse(); } @Test diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/reactive/MessageMappingMessageHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/reactive/MessageMappingMessageHandler.java index 3b4bac9453bf..d8d3e55f19c3 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/reactive/MessageMappingMessageHandler.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/reactive/MessageMappingMessageHandler.java @@ -44,6 +44,7 @@ import org.springframework.messaging.handler.CompositeMessageCondition; import org.springframework.messaging.handler.DestinationPatternsMessageCondition; import org.springframework.messaging.handler.HandlerMethod; +import org.springframework.messaging.handler.MessagingAdviceBean; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.support.AnnotationExceptionHandlerMethodResolver; import org.springframework.messaging.handler.invocation.AbstractExceptionHandlerMethodResolver; @@ -189,6 +190,40 @@ public void setEmbeddedValueResolver(StringValueResolver resolver) { this.valueResolver = resolver; } + /** + * Use this method to register a {@link MessagingAdviceBean} that may contain + * globally applicable + * {@link org.springframework.messaging.handler.annotation.MessageExceptionHandler @MessageExceptionHandler} + * methods. + *

    Note: spring-messaging does not depend on spring-web and therefore it + * is not possible to explicitly support the registration of a + * {@code @ControllerAdvice} bean. You can use the following adapter code + * to register {@code @ControllerAdvice} beans here: + *

    +	 * ControllerAdviceBean.findAnnotatedBeans(context).forEach(bean ->
    +	 *         messageHandler.registerMessagingAdvice(new ControllerAdviceWrapper(bean));
    +	 *
    +	 * public class ControllerAdviceWrapper implements MessagingAdviceBean {
    +	 *     private final ControllerAdviceBean delegate;
    +	 *     // delegate all methods
    +	 * }
    +	 * 
    + * + * @param bean the bean to check for {@code @MessageExceptionHandler} methods + * @since 5.3.5 + */ + public void registerMessagingAdvice(MessagingAdviceBean bean) { + Class type = bean.getBeanType(); + if (type != null) { + AnnotationExceptionHandlerMethodResolver resolver = new AnnotationExceptionHandlerMethodResolver(type); + if (resolver.hasExceptionMappings()) { + registerExceptionHandlerAdvice(bean, resolver); + if (logger.isTraceEnabled()) { + logger.trace("Detected @MessageExceptionHandler methods in " + bean); + } + } + } + } @Override public void afterPropertiesSet() { diff --git a/spring-messaging/src/test/java/org/springframework/messaging/simp/stomp/ReactorNettyTcpStompClientTests.java b/spring-messaging/src/test/java/org/springframework/messaging/simp/stomp/ReactorNettyTcpStompClientTests.java index 4bf79b57d2dc..90985f35d79b 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/simp/stomp/ReactorNettyTcpStompClientTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/simp/stomp/ReactorNettyTcpStompClientTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -58,7 +58,7 @@ public class ReactorNettyTcpStompClientTests { @BeforeEach - public void setUp(TestInfo testInfo) throws Exception { + public void setup(TestInfo testInfo) throws Exception { logger.debug("Setting up before '" + testInfo.getTestMethod().get().getName() + "'"); int port = SocketUtils.findAvailableTcpPort(61613); @@ -81,7 +81,7 @@ public void setUp(TestInfo testInfo) throws Exception { } @AfterEach - public void tearDown() throws Exception { + public void shutdown() throws Exception { try { this.client.shutdown(); } diff --git a/spring-messaging/src/test/java/org/springframework/messaging/simp/stomp/StompBrokerRelayMessageHandlerIntegrationTests.java b/spring-messaging/src/test/java/org/springframework/messaging/simp/stomp/StompBrokerRelayMessageHandlerIntegrationTests.java index 68753f79e807..c3c0d0293466 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/simp/stomp/StompBrokerRelayMessageHandlerIntegrationTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/simp/stomp/StompBrokerRelayMessageHandlerIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -75,7 +75,7 @@ public class StompBrokerRelayMessageHandlerIntegrationTests { @BeforeEach - public void setUp(TestInfo testInfo) throws Exception { + public void setup(TestInfo testInfo) throws Exception { logger.debug("Setting up before '" + testInfo.getTestMethod().get().getName() + "'"); this.port = SocketUtils.findAvailableTcpPort(61613); @@ -83,11 +83,11 @@ public void setUp(TestInfo testInfo) throws Exception { this.responseHandler = new TestMessageHandler(); this.responseChannel.subscribe(this.responseHandler); this.eventPublisher = new TestEventPublisher(); - startActiveMqBroker(); + startActiveMQBroker(); createAndStartRelay(); } - private void startActiveMqBroker() throws Exception { + private void startActiveMQBroker() throws Exception { this.activeMQBroker = new BrokerService(); this.activeMQBroker.addConnector("stomp://localhost:" + this.port); this.activeMQBroker.setStartAsync(false); @@ -217,7 +217,7 @@ public void relayReconnectsIfBrokerComesBackUp() throws Exception { this.eventPublisher.expectBrokerAvailabilityEvent(false); - startActiveMqBroker(); + startActiveMQBroker(); this.eventPublisher.expectBrokerAvailabilityEvent(true); } diff --git a/spring-orm/src/test/java/org/springframework/orm/jpa/AbstractContainerEntityManagerFactoryIntegrationTests.java b/spring-orm/src/test/java/org/springframework/orm/jpa/AbstractContainerEntityManagerFactoryIntegrationTests.java index 15c27ccc5f03..8885d6eb7e02 100644 --- a/spring-orm/src/test/java/org/springframework/orm/jpa/AbstractContainerEntityManagerFactoryIntegrationTests.java +++ b/spring-orm/src/test/java/org/springframework/orm/jpa/AbstractContainerEntityManagerFactoryIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -224,8 +224,7 @@ public void testQueryNoPersonsSharedNotTransactional() { q.setFlushMode(FlushModeType.AUTO); List people = q.getResultList(); assertThat(people.size()).isEqualTo(0); - assertThatExceptionOfType(Exception.class).isThrownBy(() -> - q.getSingleResult()) + assertThatExceptionOfType(Exception.class).isThrownBy(q::getSingleResult) .withMessageContaining("closed"); // We would typically expect an IllegalStateException, but Hibernate throws a // PersistenceException. So we assert the contents of the exception message instead. diff --git a/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/binding/BindMarkersFactoryResolver.java b/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/binding/BindMarkersFactoryResolver.java index e8eead572f83..31fc1ef9a198 100644 --- a/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/binding/BindMarkersFactoryResolver.java +++ b/spring-r2dbc/src/main/java/org/springframework/r2dbc/core/binding/BindMarkersFactoryResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -118,10 +118,12 @@ static class BuiltInBindMarkersFactoryProvider implements BindMarkerFactoryProvi static { BUILTIN.put("H2", BindMarkersFactory.indexed("$", 1)); + BUILTIN.put("MariaDB", BindMarkersFactory.anonymous("?")); BUILTIN.put("Microsoft SQL Server", BindMarkersFactory.named("@", "P", 32, BuiltInBindMarkersFactoryProvider::filterBindMarker)); BUILTIN.put("MySQL", BindMarkersFactory.anonymous("?")); - BUILTIN.put("MariaDB", BindMarkersFactory.anonymous("?")); + BUILTIN.put("Oracle", BindMarkersFactory.named(":", "P", 32, + BuiltInBindMarkersFactoryProvider::filterBindMarker)); BUILTIN.put("PostgreSQL", BindMarkersFactory.indexed("$", 1)); } diff --git a/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/binding/BindMarkersFactoryResolverUnitTests.java b/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/binding/BindMarkersFactoryResolverUnitTests.java new file mode 100644 index 000000000000..2314b782b7ea --- /dev/null +++ b/spring-r2dbc/src/test/java/org/springframework/r2dbc/core/binding/BindMarkersFactoryResolverUnitTests.java @@ -0,0 +1,108 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.r2dbc.core.binding; + +import io.r2dbc.spi.Connection; +import io.r2dbc.spi.ConnectionFactory; +import io.r2dbc.spi.ConnectionFactoryMetadata; +import org.junit.jupiter.api.Test; +import org.reactivestreams.Publisher; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link BindMarkersFactoryResolver}. + * + * @author Mark Paluch + */ +class BindMarkersFactoryResolverUnitTests { + + @Test + void shouldReturnBindMarkersFactoryForH2() { + + BindMarkers bindMarkers = BindMarkersFactoryResolver + .resolve(new MockConnectionFactory("H2")).create(); + + assertThat(bindMarkers.next().getPlaceholder()).isEqualTo("$1"); + } + + @Test + void shouldReturnBindMarkersFactoryForMariaDB() { + + BindMarkers bindMarkers = BindMarkersFactoryResolver + .resolve(new MockConnectionFactory("MariaDB")).create(); + + assertThat(bindMarkers.next().getPlaceholder()).isEqualTo("?"); + } + + @Test + void shouldReturnBindMarkersFactoryForMicrosoftSQLServer() { + + BindMarkers bindMarkers = BindMarkersFactoryResolver + .resolve(new MockConnectionFactory("Microsoft SQL Server")).create(); + + assertThat(bindMarkers.next("foo").getPlaceholder()).isEqualTo("@P0_foo"); + } + + @Test + void shouldReturnBindMarkersFactoryForMySQL() { + + BindMarkers bindMarkers = BindMarkersFactoryResolver + .resolve(new MockConnectionFactory("MySQL")).create(); + + assertThat(bindMarkers.next().getPlaceholder()).isEqualTo("?"); + } + + @Test + void shouldReturnBindMarkersFactoryForOracle() { + + BindMarkers bindMarkers = BindMarkersFactoryResolver + .resolve(new MockConnectionFactory("Oracle Database")).create(); + + assertThat(bindMarkers.next("foo").getPlaceholder()).isEqualTo(":P0_foo"); + } + + @Test + void shouldReturnBindMarkersFactoryForPostgreSQL() { + + BindMarkers bindMarkers = BindMarkersFactoryResolver + .resolve(new MockConnectionFactory("PostgreSQL")).create(); + + assertThat(bindMarkers.next().getPlaceholder()).isEqualTo("$1"); + } + + static class MockConnectionFactory implements ConnectionFactory { + + private final String driverName; + + MockConnectionFactory(String driverName) { + this.driverName = driverName; + } + + @Override + public Publisher create() { + throw new UnsupportedOperationException(); + } + + @Override + public ConnectionFactoryMetadata getMetadata() { + return () -> driverName; + } + + } + +} diff --git a/spring-test/src/main/java/org/springframework/mock/web/MockCookie.java b/spring-test/src/main/java/org/springframework/mock/web/MockCookie.java index 5f67f51177c1..372898cd5a36 100644 --- a/spring-test/src/main/java/org/springframework/mock/web/MockCookie.java +++ b/spring-test/src/main/java/org/springframework/mock/web/MockCookie.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,6 +22,7 @@ import javax.servlet.http.Cookie; +import org.springframework.core.style.ToStringCreator; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -152,4 +153,22 @@ private static String extractAttributeValue(String attribute, String header) { return nameAndValue[1]; } + @Override + public String toString() { + return new ToStringCreator(this) + .append("name", getName()) + .append("value", getValue()) + .append("Path", getPath()) + .append("Domain", getDomain()) + .append("Version", getVersion()) + .append("Comment", getComment()) + .append("Secure", getSecure()) + .append("HttpOnly", isHttpOnly()) + .append("SameSite", this.sameSite) + .append("Max-Age", getMaxAge()) + .append("Expires", (this.expires != null ? + DateTimeFormatter.RFC_1123_DATE_TIME.format(this.expires) : null)) + .toString(); + } + } diff --git a/spring-test/src/main/java/org/springframework/mock/web/MockHttpServletMapping.java b/spring-test/src/main/java/org/springframework/mock/web/MockHttpServletMapping.java index 8faa21bf690a..62664d5dac32 100644 --- a/spring-test/src/main/java/org/springframework/mock/web/MockHttpServletMapping.java +++ b/spring-test/src/main/java/org/springframework/mock/web/MockHttpServletMapping.java @@ -24,6 +24,10 @@ /** * Mock implementation of {@link HttpServletMapping}. * + *

    Currently not exposed in {@link MockHttpServletRequest} as a setter to + * avoid issues for Maven builds in applications with a Servlet 3.1 runtime + * requirement. + * * @author Rossen Stoyanchev * @since 5.3.4 */ diff --git a/spring-test/src/main/java/org/springframework/mock/web/MockHttpServletRequest.java b/spring-test/src/main/java/org/springframework/mock/web/MockHttpServletRequest.java index b7126ee294d2..e12c278baca2 100644 --- a/spring-test/src/main/java/org/springframework/mock/web/MockHttpServletRequest.java +++ b/spring-test/src/main/java/org/springframework/mock/web/MockHttpServletRequest.java @@ -52,7 +52,6 @@ import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.http.Cookie; -import javax.servlet.http.HttpServletMapping; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; @@ -275,8 +274,6 @@ public class MockHttpServletRequest implements HttpServletRequest { private final MultiValueMap parts = new LinkedMultiValueMap<>(); - private HttpServletMapping httpServletMapping = new MockHttpServletMapping("", "", "", null); - // --------------------------------------------------------------------- // Constructors @@ -1393,15 +1390,6 @@ public Collection getParts() throws IOException, ServletException { return result; } - public void setHttpServletMapping(HttpServletMapping httpServletMapping) { - this.httpServletMapping = httpServletMapping; - } - - @Override - public HttpServletMapping getHttpServletMapping() { - return this.httpServletMapping; - } - @Override public T upgrade(Class handlerClass) throws IOException, ServletException { throw new UnsupportedOperationException(); diff --git a/spring-test/src/main/java/org/springframework/mock/web/MockHttpServletResponse.java b/spring-test/src/main/java/org/springframework/mock/web/MockHttpServletResponse.java index 695c1277dcf2..b7b6b9626aa8 100644 --- a/spring-test/src/main/java/org/springframework/mock/web/MockHttpServletResponse.java +++ b/spring-test/src/main/java/org/springframework/mock/web/MockHttpServletResponse.java @@ -378,10 +378,10 @@ private String getCookieHeader(Cookie cookie) { buf.append("; Domain=").append(cookie.getDomain()); } int maxAge = cookie.getMaxAge(); + ZonedDateTime expires = (cookie instanceof MockCookie ? ((MockCookie) cookie).getExpires() : null); if (maxAge >= 0) { buf.append("; Max-Age=").append(maxAge); buf.append("; Expires="); - ZonedDateTime expires = (cookie instanceof MockCookie ? ((MockCookie) cookie).getExpires() : null); if (expires != null) { buf.append(expires.format(DateTimeFormatter.RFC_1123_DATE_TIME)); } @@ -391,6 +391,10 @@ private String getCookieHeader(Cookie cookie) { buf.append(headers.getFirst(HttpHeaders.EXPIRES)); } } + else if (expires != null) { + buf.append("; Expires="); + buf.append(expires.format(DateTimeFormatter.RFC_1123_DATE_TIME)); + } if (cookie.getSecure()) { buf.append("; Secure"); diff --git a/spring-test/src/main/java/org/springframework/test/context/junit/jupiter/AbstractExpressionEvaluatingCondition.java b/spring-test/src/main/java/org/springframework/test/context/junit/jupiter/AbstractExpressionEvaluatingCondition.java index 8c21fe375be3..22a9cfa8e332 100644 --- a/spring-test/src/main/java/org/springframework/test/context/junit/jupiter/AbstractExpressionEvaluatingCondition.java +++ b/spring-test/src/main/java/org/springframework/test/context/junit/jupiter/AbstractExpressionEvaluatingCondition.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2017 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -34,6 +34,9 @@ import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.support.GenericApplicationContext; import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.annotation.DirtiesContext.HierarchyMode; +import org.springframework.test.context.TestContextAnnotationUtils; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -105,6 +108,7 @@ protected ConditionEvaluationResult evaluateAnnotation(Cl boolean loadContext = loadContextExtractor.apply(annotation.get()); boolean evaluatedToTrue = evaluateExpression(expression, loadContext, annotationType, context); + ConditionEvaluationResult result; if (evaluatedToTrue) { String adjective = (enabledOnTrue ? "enabled" : "disabled"); @@ -114,7 +118,7 @@ protected ConditionEvaluationResult evaluateAnnotation(Cl if (logger.isInfoEnabled()) { logger.info(reason); } - return (enabledOnTrue ? ConditionEvaluationResult.enabled(reason) + result = (enabledOnTrue ? ConditionEvaluationResult.enabled(reason) : ConditionEvaluationResult.disabled(reason)); } else { @@ -124,9 +128,26 @@ protected ConditionEvaluationResult evaluateAnnotation(Cl if (logger.isDebugEnabled()) { logger.debug(reason); } - return (enabledOnTrue ? ConditionEvaluationResult.disabled(reason) : + result = (enabledOnTrue ? ConditionEvaluationResult.disabled(reason) : ConditionEvaluationResult.enabled(reason)); } + + // If we eagerly loaded the ApplicationContext to evaluate SpEL expressions + // and the test class ends up being disabled, we have to check if the + // user asked for the ApplicationContext to be closed via @DirtiesContext, + // since the DirtiesContextTestExecutionListener will never be invoked for + // a disabled test class. + // See https://github.com/spring-projects/spring-framework/issues/26694 + if (loadContext && result.isDisabled() && element instanceof Class) { + Class testClass = (Class) element; + DirtiesContext dirtiesContext = TestContextAnnotationUtils.findMergedAnnotation(testClass, DirtiesContext.class); + if (dirtiesContext != null) { + HierarchyMode hierarchyMode = dirtiesContext.hierarchyMode(); + SpringExtension.getTestContextManager(context).getTestContext().markApplicationContextDirty(hierarchyMode); + } + } + + return result; } private boolean evaluateExpression(String expression, boolean loadContext, diff --git a/spring-test/src/main/java/org/springframework/test/context/junit/jupiter/SpringExtension.java b/spring-test/src/main/java/org/springframework/test/context/junit/jupiter/SpringExtension.java index 83030f37b22c..dfd7aaaa28cd 100644 --- a/spring-test/src/main/java/org/springframework/test/context/junit/jupiter/SpringExtension.java +++ b/spring-test/src/main/java/org/springframework/test/context/junit/jupiter/SpringExtension.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -287,7 +287,7 @@ public static ApplicationContext getApplicationContext(ExtensionContext context) * Get the {@link TestContextManager} associated with the supplied {@code ExtensionContext}. * @return the {@code TestContextManager} (never {@code null}) */ - private static TestContextManager getTestContextManager(ExtensionContext context) { + static TestContextManager getTestContextManager(ExtensionContext context) { Assert.notNull(context, "ExtensionContext must not be null"); Class testClass = context.getRequiredTestClass(); Store store = getStore(context); diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/CookieAssertions.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/CookieAssertions.java index 9d1cad065981..0745ef7d7fd1 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/CookieAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/CookieAssertions.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package org.springframework.test.web.reactive.server; import java.time.Duration; @@ -28,7 +29,9 @@ /** * Assertions on cookies of the response. + * * @author Rossen Stoyanchev + * @since 5.3 */ public class CookieAssertions { diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java index c0fd64bb8ed5..8d0ec7de0c5d 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -79,6 +79,8 @@ class DefaultWebTestClient implements WebTestClient { @Nullable private final MultiValueMap defaultCookies; + private final Consumer> entityResultConsumer; + private final Duration responseTimeout; private final DefaultWebTestClientBuilder builder; @@ -89,6 +91,7 @@ class DefaultWebTestClient implements WebTestClient { DefaultWebTestClient(ClientHttpConnector connector, Function exchangeFactory, UriBuilderFactory uriBuilderFactory, @Nullable HttpHeaders headers, @Nullable MultiValueMap cookies, + Consumer> entityResultConsumer, @Nullable Duration responseTimeout, DefaultWebTestClientBuilder clientBuilder) { this.wiretapConnector = new WiretapConnector(connector); @@ -96,6 +99,7 @@ class DefaultWebTestClient implements WebTestClient { this.uriBuilderFactory = uriBuilderFactory; this.defaultHeaders = headers; this.defaultCookies = cookies; + this.entityResultConsumer = entityResultConsumer; this.responseTimeout = (responseTimeout != null ? responseTimeout : Duration.ofSeconds(5)); this.builder = clientBuilder; } @@ -357,7 +361,8 @@ public ResponseSpec exchange() { ExchangeResult result = wiretapConnector.getExchangeResult( this.requestId, this.uriTemplate, getResponseTimeout()); - return new DefaultResponseSpec(result, response, getResponseTimeout()); + return new DefaultResponseSpec(result, response, + DefaultWebTestClient.this.entityResultConsumer, getResponseTimeout()); } private ClientRequest.Builder initRequestBuilder() { @@ -408,12 +413,19 @@ private static class DefaultResponseSpec implements ResponseSpec { private final ClientResponse response; + private final Consumer> entityResultConsumer; + private final Duration timeout; - DefaultResponseSpec(ExchangeResult exchangeResult, ClientResponse response, Duration timeout) { + DefaultResponseSpec( + ExchangeResult exchangeResult, ClientResponse response, + Consumer> entityResultConsumer, + Duration timeout) { + this.exchangeResult = exchangeResult; this.response = response; + this.entityResultConsumer = entityResultConsumer; this.timeout = timeout; } @@ -435,14 +447,14 @@ public CookieAssertions expectCookie() { @Override public BodySpec expectBody(Class bodyType) { B body = this.response.bodyToMono(bodyType).block(this.timeout); - EntityExchangeResult entityResult = new EntityExchangeResult<>(this.exchangeResult, body); + EntityExchangeResult entityResult = initEntityExchangeResult(body); return new DefaultBodySpec<>(entityResult); } @Override public BodySpec expectBody(ParameterizedTypeReference bodyType) { B body = this.response.bodyToMono(bodyType).block(this.timeout); - EntityExchangeResult entityResult = new EntityExchangeResult<>(this.exchangeResult, body); + EntityExchangeResult entityResult = initEntityExchangeResult(body); return new DefaultBodySpec<>(entityResult); } @@ -459,7 +471,7 @@ public ListBodySpec expectBodyList(ParameterizedTypeReference elementT private ListBodySpec getListBodySpec(Flux flux) { List body = flux.collectList().block(this.timeout); - EntityExchangeResult> entityResult = new EntityExchangeResult<>(this.exchangeResult, body); + EntityExchangeResult> entityResult = initEntityExchangeResult(body); return new DefaultListBodySpec<>(entityResult); } @@ -467,10 +479,16 @@ private ListBodySpec getListBodySpec(Flux flux) { public BodyContentSpec expectBody() { ByteArrayResource resource = this.response.bodyToMono(ByteArrayResource.class).block(this.timeout); byte[] body = (resource != null ? resource.getByteArray() : null); - EntityExchangeResult entityResult = new EntityExchangeResult<>(this.exchangeResult, body); + EntityExchangeResult entityResult = initEntityExchangeResult(body); return new DefaultBodyContentSpec(entityResult); } + private EntityExchangeResult initEntityExchangeResult(@Nullable B body) { + EntityExchangeResult result = new EntityExchangeResult<>(this.exchangeResult, body); + result.assertWithDiagnostics(() -> this.entityResultConsumer.accept(result)); + return result; + } + @Override public FluxExchangeResult returnResult(Class elementClass) { Flux body; diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClientBuilder.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClientBuilder.java index 4a398e19a5be..f403ad913846 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClientBuilder.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClientBuilder.java @@ -92,6 +92,8 @@ class DefaultWebTestClientBuilder implements WebTestClient.Builder { @Nullable private List filters; + private Consumer> entityResultConsumer = result -> {}; + @Nullable private ExchangeStrategies strategies; @@ -149,6 +151,7 @@ class DefaultWebTestClientBuilder implements WebTestClient.Builder { this.defaultCookies = (other.defaultCookies != null ? new LinkedMultiValueMap<>(other.defaultCookies) : null); this.filters = (other.filters != null ? new ArrayList<>(other.filters) : null); + this.entityResultConsumer = other.entityResultConsumer; this.strategies = other.strategies; this.strategiesConfigurers = (other.strategiesConfigurers != null ? new ArrayList<>(other.strategiesConfigurers) : null); @@ -207,7 +210,7 @@ private MultiValueMap initCookies() { @Override public WebTestClient.Builder filter(ExchangeFilterFunction filter) { - Assert.notNull(filter, "ExchangeFilterFunction must not be null"); + Assert.notNull(filter, "ExchangeFilterFunction is required"); initFilters().add(filter); return this; } @@ -225,6 +228,13 @@ private List initFilters() { return this.filters; } + @Override + public WebTestClient.Builder entityExchangeResultConsumer(Consumer> entityResultConsumer) { + Assert.notNull(entityResultConsumer, "`entityResultConsumer` is required"); + this.entityResultConsumer = this.entityResultConsumer.andThen(entityResultConsumer); + return this; + } + @Override public WebTestClient.Builder codecs(Consumer configurer) { if (this.strategiesConfigurers == null) { @@ -287,7 +297,7 @@ public WebTestClient build() { return new DefaultWebTestClient(connectorToUse, exchangeFactory, initUriBuilderFactory(), this.defaultHeaders != null ? HttpHeaders.readOnlyHttpHeaders(this.defaultHeaders) : null, this.defaultCookies != null ? CollectionUtils.unmodifiableMultiValueMap(this.defaultCookies) : null, - this.responseTimeout, new DefaultWebTestClientBuilder(this)); + this.entityResultConsumer, this.responseTimeout, new DefaultWebTestClientBuilder(this)); } private static ClientHttpConnector initConnector() { diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/StatusAssertions.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/StatusAssertions.java index 497b5bace1ac..5c3641e31bf8 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/StatusAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/StatusAssertions.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -71,8 +71,7 @@ public WebTestClient.ResponseSpec isOk() { * Assert the response status code is {@code HttpStatus.CREATED} (201). */ public WebTestClient.ResponseSpec isCreated() { - HttpStatus expected = HttpStatus.CREATED; - return assertStatusAndReturn(expected); + return assertStatusAndReturn(HttpStatus.CREATED); } /** @@ -158,8 +157,8 @@ public WebTestClient.ResponseSpec isNotFound() { */ public WebTestClient.ResponseSpec reasonEquals(String reason) { String actual = this.exchangeResult.getStatus().getReasonPhrase(); - String message = "Response status reason"; - this.exchangeResult.assertWithDiagnostics(() -> AssertionErrors.assertEquals(message, reason, actual)); + this.exchangeResult.assertWithDiagnostics(() -> + AssertionErrors.assertEquals("Response status reason", reason, actual)); return this.responseSpec; } @@ -195,8 +194,7 @@ public WebTestClient.ResponseSpec is4xxClientError() { * Assert the response status code is in the 5xx range. */ public WebTestClient.ResponseSpec is5xxServerError() { - HttpStatus.Series expected = HttpStatus.Series.SERVER_ERROR; - return assertSeriesAndReturn(expected); + return assertSeriesAndReturn(HttpStatus.Series.SERVER_ERROR); } /** @@ -205,8 +203,8 @@ public WebTestClient.ResponseSpec is5xxServerError() { * @since 5.1 */ public WebTestClient.ResponseSpec value(Matcher matcher) { - int value = this.exchangeResult.getStatus().value(); - this.exchangeResult.assertWithDiagnostics(() -> MatcherAssert.assertThat("Response status", value, matcher)); + int actual = this.exchangeResult.getRawStatusCode(); + this.exchangeResult.assertWithDiagnostics(() -> MatcherAssert.assertThat("Response status", actual, matcher)); return this.responseSpec; } @@ -216,8 +214,8 @@ public WebTestClient.ResponseSpec value(Matcher matcher) { * @since 5.1 */ public WebTestClient.ResponseSpec value(Consumer consumer) { - int value = this.exchangeResult.getStatus().value(); - this.exchangeResult.assertWithDiagnostics(() -> consumer.accept(value)); + int actual = this.exchangeResult.getRawStatusCode(); + this.exchangeResult.assertWithDiagnostics(() -> consumer.accept(actual)); return this.responseSpec; } @@ -230,10 +228,8 @@ private WebTestClient.ResponseSpec assertStatusAndReturn(HttpStatus expected) { private WebTestClient.ResponseSpec assertSeriesAndReturn(HttpStatus.Series expected) { HttpStatus status = this.exchangeResult.getStatus(); - this.exchangeResult.assertWithDiagnostics(() -> { - String message = "Range for response status value " + status; - AssertionErrors.assertEquals(message, expected, status.series()); - }); + this.exchangeResult.assertWithDiagnostics(() -> + AssertionErrors.assertEquals("Range for response status value " + status, expected, status.series())); return this.responseSpec; } diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java index f2436c80d32d..6d2fcefebd70 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -438,6 +438,33 @@ interface Builder { */ Builder filters(Consumer> filtersConsumer); + /** + * Configure an {@code EntityExchangeResult} callback that is invoked + * every time after a response is fully decoded to a single entity, to a + * List of entities, or to a byte[]. In effect, equivalent to each and + * all of the below but registered once, globally: + *

    +		 * client.get().uri("/accounts/1")
    +		 *         .exchange()
    +		 *         .expectBody(Person.class).consumeWith(exchangeResult -> ... ));
    +		 *
    +		 * client.get().uri("/accounts")
    +		 *         .exchange()
    +		 *         .expectBodyList(Person.class).consumeWith(exchangeResult -> ... ));
    +		 *
    +		 * client.get().uri("/accounts/1")
    +		 *         .exchange()
    +		 *         .expectBody().consumeWith(exchangeResult -> ... ));
    +		 * 
    + *

    Note that the configured consumer does not apply to responses + * decoded to {@code Flux} which can be consumed outside the workflow + * of the test client, for example via {@code reactor.test.StepVerifier}. + * @param consumer the consumer to apply to entity responses + * @return the builder + * @since 5.3.5 + */ + Builder entityExchangeResultConsumer(Consumer> consumer); + /** * Configure the codecs for the {@code WebClient} in the * {@link #exchangeStrategies(ExchangeStrategies) underlying} diff --git a/spring-test/src/test/java/org/springframework/mock/web/MockFilterChainTests.java b/spring-test/src/test/java/org/springframework/mock/web/MockFilterChainTests.java index 5b0f4bf4edff..4babe4467a32 100644 --- a/spring-test/src/test/java/org/springframework/mock/web/MockFilterChainTests.java +++ b/spring-test/src/test/java/org/springframework/mock/web/MockFilterChainTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -55,7 +55,7 @@ void setup() { @Test void constructorNullServlet() { assertThatIllegalArgumentException().isThrownBy(() -> - new MockFilterChain((Servlet) null)); + new MockFilterChain(null)); } @Test diff --git a/spring-test/src/test/java/org/springframework/mock/web/MockHttpServletRequestTests.java b/spring-test/src/test/java/org/springframework/mock/web/MockHttpServletRequestTests.java index ed837c9267c5..6ade7c90a710 100644 --- a/spring-test/src/test/java/org/springframework/mock/web/MockHttpServletRequestTests.java +++ b/spring-test/src/test/java/org/springframework/mock/web/MockHttpServletRequestTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,6 +19,7 @@ import java.io.IOException; import java.net.URL; import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -112,7 +113,7 @@ void getContentAsStringWithoutSettingCharacterEncoding() throws IOException { @Test void setContentAndGetContentAsStringWithExplicitCharacterEncoding() throws IOException { String palindrome = "ablE was I ere I saw Elba"; - byte[] bytes = palindrome.getBytes("UTF-16"); + byte[] bytes = palindrome.getBytes(StandardCharsets.UTF_16); request.setCharacterEncoding("UTF-16"); request.setContent(bytes); assertThat(request.getContentLength()).isEqualTo(bytes.length); @@ -394,7 +395,7 @@ void getServerNameViaHostHeaderWithPort() { void getServerNameWithInvalidIpv6AddressViaHostHeader() { request.addHeader(HOST, "[::ffff:abcd:abcd"); // missing closing bracket assertThatIllegalStateException() - .isThrownBy(() -> request.getServerName()) + .isThrownBy(request::getServerName) .withMessageStartingWith("Invalid Host header: "); } @@ -426,7 +427,7 @@ void getServerPortWithCustomPort() { void getServerPortWithInvalidIpv6AddressViaHostHeader() { request.addHeader(HOST, "[::ffff:abcd:abcd:8080"); // missing closing bracket assertThatIllegalStateException() - .isThrownBy(() -> request.getServerPort()) + .isThrownBy(request::getServerPort) .withMessageStartingWith("Invalid Host header: "); } @@ -434,7 +435,7 @@ void getServerPortWithInvalidIpv6AddressViaHostHeader() { void getServerPortWithIpv6AddressAndInvalidPortViaHostHeader() { request.addHeader(HOST, "[::ffff:abcd:abcd]:bogus"); // "bogus" is not a port number assertThatExceptionOfType(NumberFormatException.class) - .isThrownBy(() -> request.getServerPort()) + .isThrownBy(request::getServerPort) .withMessageContaining("bogus"); } @@ -521,7 +522,7 @@ void getRequestURLWithIpv6AddressViaServerNameWithPort() throws Exception { void getRequestURLWithInvalidIpv6AddressViaHostHeader() { request.addHeader(HOST, "[::ffff:abcd:abcd"); // missing closing bracket assertThatIllegalStateException() - .isThrownBy(() -> request.getRequestURL()) + .isThrownBy(request::getRequestURL) .withMessageStartingWith("Invalid Host header: "); } diff --git a/spring-test/src/test/java/org/springframework/mock/web/MockHttpServletResponseTests.java b/spring-test/src/test/java/org/springframework/mock/web/MockHttpServletResponseTests.java index 08fda3f72372..02e90ba16f6b 100644 --- a/spring-test/src/test/java/org/springframework/mock/web/MockHttpServletResponseTests.java +++ b/spring-test/src/test/java/org/springframework/mock/web/MockHttpServletResponseTests.java @@ -18,6 +18,8 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; import java.util.Arrays; import java.util.Collection; import java.util.Locale; @@ -415,12 +417,17 @@ void setCookieHeader() { * @since 5.1.11 */ @Test - void setCookieHeaderWithExpiresAttribute() { - String cookieValue = "SESSION=123; Path=/; Max-Age=100; Expires=Tue, 8 Oct 2019 19:50:00 GMT; Secure; " + - "HttpOnly; SameSite=Lax"; + void setCookieHeaderWithMaxAgeAndExpiresAttributes() { + String expiryDate = "Tue, 8 Oct 2019 19:50:00 GMT"; + String cookieValue = "SESSION=123; Path=/; Max-Age=100; Expires=" + expiryDate + "; Secure; HttpOnly; SameSite=Lax"; response.setHeader(SET_COOKIE, cookieValue); - assertNumCookies(1); assertThat(response.getHeader(SET_COOKIE)).isEqualTo(cookieValue); + + assertNumCookies(1); + assertThat(response.getCookies()[0]).isInstanceOf(MockCookie.class); + MockCookie mockCookie = (MockCookie) response.getCookies()[0]; + assertThat(mockCookie.getMaxAge()).isEqualTo(100); + assertThat(mockCookie.getExpires()).isEqualTo(ZonedDateTime.parse(expiryDate, DateTimeFormatter.RFC_1123_DATE_TIME)); } /** @@ -454,18 +461,24 @@ void addCookieHeader() { * @since 5.1.11 */ @Test - void addCookieHeaderWithExpiresAttribute() { - String cookieValue = "SESSION=123; Path=/; Max-Age=100; Expires=Tue, 8 Oct 2019 19:50:00 GMT; Secure; " + - "HttpOnly; SameSite=Lax"; + void addCookieHeaderWithMaxAgeAndExpiresAttributes() { + String expiryDate = "Tue, 8 Oct 2019 19:50:00 GMT"; + String cookieValue = "SESSION=123; Path=/; Max-Age=100; Expires=" + expiryDate + "; Secure; HttpOnly; SameSite=Lax"; response.addHeader(SET_COOKIE, cookieValue); assertThat(response.getHeader(SET_COOKIE)).isEqualTo(cookieValue); + + assertNumCookies(1); + assertThat(response.getCookies()[0]).isInstanceOf(MockCookie.class); + MockCookie mockCookie = (MockCookie) response.getCookies()[0]; + assertThat(mockCookie.getMaxAge()).isEqualTo(100); + assertThat(mockCookie.getExpires()).isEqualTo(ZonedDateTime.parse(expiryDate, DateTimeFormatter.RFC_1123_DATE_TIME)); } /** * @since 5.1.12 */ @Test - void addCookieHeaderWithZeroExpiresAttribute() { + void addCookieHeaderWithMaxAgeAndZeroExpiresAttributes() { String cookieValue = "SESSION=123; Path=/; Max-Age=100; Expires=0"; response.addHeader(SET_COOKIE, cookieValue); assertNumCookies(1); @@ -475,6 +488,27 @@ void addCookieHeaderWithZeroExpiresAttribute() { assertThat(header).startsWith("SESSION=123; Path=/; Max-Age=100; Expires="); } + /** + * @since 5.2.14 + */ + @Test + void addCookieHeaderWithExpiresAttributeWithoutMaxAgeAttribute() { + String expiryDate = "Tue, 8 Oct 2019 19:50:00 GMT"; + String cookieValue = "SESSION=123; Path=/; Expires=" + expiryDate; + response.addHeader(SET_COOKIE, cookieValue); + System.err.println(response.getCookie("SESSION")); + assertThat(response.getHeader(SET_COOKIE)).isEqualTo(cookieValue); + + assertNumCookies(1); + assertThat(response.getCookies()[0]).isInstanceOf(MockCookie.class); + MockCookie mockCookie = (MockCookie) response.getCookies()[0]; + assertThat(mockCookie.getName()).isEqualTo("SESSION"); + assertThat(mockCookie.getValue()).isEqualTo("123"); + assertThat(mockCookie.getPath()).isEqualTo("/"); + assertThat(mockCookie.getMaxAge()).isEqualTo(-1); + assertThat(mockCookie.getExpires()).isEqualTo(ZonedDateTime.parse(expiryDate, DateTimeFormatter.RFC_1123_DATE_TIME)); + } + @Test void addCookie() { MockCookie mockCookie = new MockCookie("SESSION", "123"); diff --git a/spring-test/src/test/java/org/springframework/test/context/TestContextAnnotationUtilsTests.java b/spring-test/src/test/java/org/springframework/test/context/TestContextAnnotationUtilsTests.java index d5eee230de54..2755038a7d7a 100644 --- a/spring-test/src/test/java/org/springframework/test/context/TestContextAnnotationUtilsTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/TestContextAnnotationUtilsTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -557,10 +557,10 @@ private void assertAtComponentOnComposedAnnotationForMultipleCandidateTypes(Clas @Target(ElementType.TYPE) @interface MetaConfig { - static class DevConfig { + class DevConfig { } - static class ProductionConfig { + class ProductionConfig { } @@ -607,11 +607,11 @@ static class MetaCycleAnnotatedClass { } @MetaConfig - class MetaConfigWithDefaultAttributesTestCase { + static class MetaConfigWithDefaultAttributesTestCase { } @MetaConfig(classes = TestContextAnnotationUtilsTests.class) - class MetaConfigWithOverriddenAttributesTestCase { + static class MetaConfigWithOverriddenAttributesTestCase { } // ------------------------------------------------------------------------- diff --git a/spring-test/src/test/java/org/springframework/test/context/junit/jupiter/DisabledIfAndDirtiesContextTests.java b/spring-test/src/test/java/org/springframework/test/context/junit/jupiter/DisabledIfAndDirtiesContextTests.java new file mode 100644 index 000000000000..007ab98660f8 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/junit/jupiter/DisabledIfAndDirtiesContextTests.java @@ -0,0 +1,107 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.test.context.junit.jupiter; + +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.platform.testkit.engine.EngineTestKit; + +import org.springframework.beans.factory.DisposableBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.annotation.DirtiesContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; +import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass; + +/** + * Integration tests which verify support for {@link DisabledIf @DisabledIf} in + * conjunction with {@link DirtiesContext @DirtiesContext} and the + * {@link SpringExtension} in a JUnit Jupiter environment. + * + * @author Sam Brannen + * @since 5.2.14 + * @see EnabledIfAndDirtiesContextTests + */ +class DisabledIfAndDirtiesContextTests { + + private static AtomicBoolean contextClosed = new AtomicBoolean(); + + + @BeforeEach + void reset() { + contextClosed.set(false); + } + + @Test + void contextShouldBeClosedForEnabledTestClass() { + assertThat(contextClosed).as("context closed").isFalse(); + EngineTestKit.engine("junit-jupiter").selectors( + selectClass(EnabledAndDirtiesContextTestCase.class))// + .execute()// + .testEvents()// + .assertStatistics(stats -> stats.started(1).succeeded(1).failed(0)); + assertThat(contextClosed).as("context closed").isTrue(); + } + + @Test + void contextShouldBeClosedForDisabledTestClass() { + assertThat(contextClosed).as("context closed").isFalse(); + EngineTestKit.engine("junit-jupiter").selectors( + selectClass(DisabledAndDirtiesContextTestCase.class))// + .execute()// + .testEvents()// + .assertStatistics(stats -> stats.started(0).succeeded(0).failed(0)); + assertThat(contextClosed).as("context closed").isTrue(); + } + + + @SpringJUnitConfig(Config.class) + @DisabledIf(expression = "false", loadContext = true) + @DirtiesContext + static class EnabledAndDirtiesContextTestCase { + + @Test + void test() { + /* no-op */ + } + } + + @SpringJUnitConfig(Config.class) + @DisabledIf(expression = "true", loadContext = true) + @DirtiesContext + static class DisabledAndDirtiesContextTestCase { + + @Test + void test() { + fail("This test must be disabled"); + } + } + + @Configuration + static class Config { + + @Bean + DisposableBean disposableBean() { + return () -> contextClosed.set(true); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/junit/jupiter/EnabledIfAndDirtiesContextTests.java b/spring-test/src/test/java/org/springframework/test/context/junit/jupiter/EnabledIfAndDirtiesContextTests.java new file mode 100644 index 000000000000..1cbe48b8a604 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/junit/jupiter/EnabledIfAndDirtiesContextTests.java @@ -0,0 +1,107 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.test.context.junit.jupiter; + +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.platform.testkit.engine.EngineTestKit; + +import org.springframework.beans.factory.DisposableBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.annotation.DirtiesContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; +import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass; + +/** + * Integration tests which verify support for {@link EnabledIf @EnabledIf} in + * conjunction with {@link DirtiesContext @DirtiesContext} and the + * {@link SpringExtension} in a JUnit Jupiter environment. + * + * @author Sam Brannen + * @since 5.2.14 + * @see DisabledIfAndDirtiesContextTests + */ +class EnabledIfAndDirtiesContextTests { + + private static AtomicBoolean contextClosed = new AtomicBoolean(); + + + @BeforeEach + void reset() { + contextClosed.set(false); + } + + @Test + void contextShouldBeClosedForEnabledTestClass() { + assertThat(contextClosed).as("context closed").isFalse(); + EngineTestKit.engine("junit-jupiter").selectors( + selectClass(EnabledAndDirtiesContextTestCase.class))// + .execute()// + .testEvents()// + .assertStatistics(stats -> stats.started(1).succeeded(1).failed(0)); + assertThat(contextClosed).as("context closed").isTrue(); + } + + @Test + void contextShouldBeClosedForDisabledTestClass() { + assertThat(contextClosed).as("context closed").isFalse(); + EngineTestKit.engine("junit-jupiter").selectors( + selectClass(DisabledAndDirtiesContextTestCase.class))// + .execute()// + .testEvents()// + .assertStatistics(stats -> stats.started(0).succeeded(0).failed(0)); + assertThat(contextClosed).as("context closed").isTrue(); + } + + + @SpringJUnitConfig(Config.class) + @EnabledIf(expression = "true", loadContext = true) + @DirtiesContext + static class EnabledAndDirtiesContextTestCase { + + @Test + void test() { + /* no-op */ + } + } + + @SpringJUnitConfig(Config.class) + @EnabledIf(expression = "false", loadContext = true) + @DirtiesContext + static class DisabledAndDirtiesContextTestCase { + + @Test + void test() { + fail("This test must be disabled"); + } + } + + @Configuration + static class Config { + + @Bean + DisposableBean disposableBean() { + return () -> contextClosed.set(true); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/context/junit/jupiter/event/ParallelApplicationEventsIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/context/junit/jupiter/event/ParallelApplicationEventsIntegrationTests.java index ec7b061c1ae2..bf12679653f8 100644 --- a/spring-test/src/test/java/org/springframework/test/context/junit/jupiter/event/ParallelApplicationEventsIntegrationTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/junit/jupiter/event/ParallelApplicationEventsIntegrationTests.java @@ -68,13 +68,17 @@ void executeTestsInParallel(Class testClass) { Set testNames = payloads.stream()// .map(payload -> payload.substring(0, payload.indexOf("-")))// .collect(Collectors.toSet()); - Set threadNames = payloads.stream()// - .map(payload -> payload.substring(payload.indexOf("-")))// - .collect(Collectors.toSet()); assertThat(payloads).hasSize(10); assertThat(testNames).hasSize(10); + // The following assertion is currently commented out, since it fails + // regularly on the CI server due to only 1 thread being used for + // parallel test execution on the CI server. + /* + Set threadNames = payloads.stream()// + .map(payload -> payload.substring(payload.indexOf("-")))// + .collect(Collectors.toSet()); int availableProcessors = Runtime.getRuntime().availableProcessors(); // Skip the following assertion entirely if too few processors are available // to the current JVM. @@ -86,6 +90,7 @@ void executeTestsInParallel(Class testClass) { .as("number of threads used with " + availableProcessors + " available processors") .hasSizeGreaterThanOrEqualTo(2); } + */ } diff --git a/spring-test/src/test/java/org/springframework/test/web/reactive/server/StatusAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/reactive/server/StatusAssertionTests.java index 7a301ad91619..f84c2fc75a11 100644 --- a/spring-test/src/test/java/org/springframework/test/web/reactive/server/StatusAssertionTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/reactive/server/StatusAssertionTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -56,7 +56,7 @@ void isEqualTo() { assertions.isEqualTo(408)); } - @Test // gh-23630 + @Test // gh-23630 void isEqualToWithCustomStatus() { statusAssertions(600).isEqualTo(600); } @@ -74,20 +74,19 @@ void reasonEquals() { } @Test - void statusSerius1xx() { + void statusSeries1xx() { StatusAssertions assertions = statusAssertions(HttpStatus.CONTINUE); // Success assertions.is1xxInformational(); // Wrong series - assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.is2xxSuccessful()); } @Test - void statusSerius2xx() { + void statusSeries2xx() { StatusAssertions assertions = statusAssertions(HttpStatus.OK); // Success @@ -99,7 +98,7 @@ void statusSerius2xx() { } @Test - void statusSerius3xx() { + void statusSeries3xx() { StatusAssertions assertions = statusAssertions(HttpStatus.PERMANENT_REDIRECT); // Success @@ -111,7 +110,7 @@ void statusSerius3xx() { } @Test - void statusSerius4xx() { + void statusSeries4xx() { StatusAssertions assertions = statusAssertions(HttpStatus.BAD_REQUEST); // Success @@ -123,7 +122,7 @@ void statusSerius4xx() { } @Test - void statusSerius5xx() { + void statusSeries5xx() { StatusAssertions assertions = statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR); // Success @@ -135,7 +134,7 @@ void statusSerius5xx() { } @Test - void matches() { + void matchesStatusValue() { StatusAssertions assertions = statusAssertions(HttpStatus.CONFLICT); // Success @@ -147,6 +146,11 @@ void matches() { assertions.value(equalTo(200))); } + @Test // gh-26658 + void matchesCustomStatusValue() { + statusAssertions(600).value(equalTo(600)); + } + private StatusAssertions statusAssertions(HttpStatus status) { return statusAssertions(status.value()); diff --git a/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/GlobalEntityResultConsumerTests.java b/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/GlobalEntityResultConsumerTests.java new file mode 100644 index 000000000000..8404c9ed7ea3 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/GlobalEntityResultConsumerTests.java @@ -0,0 +1,98 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.test.web.reactive.server.samples; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.http.MediaType; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests with a globally registered + * {@link org.springframework.test.web.reactive.server.EntityExchangeResult} consumer. + * + * @author Rossen Stoyanchev + */ +public class GlobalEntityResultConsumerTests { + + private final StringBuilder output = new StringBuilder(); + + private final WebTestClient client = WebTestClient.bindToController(TestController.class) + .configureClient() + .entityExchangeResultConsumer(result -> { + byte[] bytes = result.getResponseBodyContent(); + this.output.append(new String(bytes, StandardCharsets.UTF_8)); + }) + .build(); + + + @Test + void json() { + this.client.get().uri("/person/1") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isOk() + .expectBody().json("{\"name\":\"Joe\"}"); + + assertThat(this.output.toString()).isEqualTo("{\"name\":\"Joe\"}"); + } + + @Test + void entity() { + this.client.get().uri("/person/1") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isOk() + .expectBody(Person.class).isEqualTo(new Person("Joe")); + + assertThat(this.output.toString()).isEqualTo("{\"name\":\"Joe\"}"); + } + + @Test + void entityList() { + this.client.get().uri("/persons") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isOk() + .expectBodyList(Person.class).hasSize(2); + + assertThat(this.output.toString()) + .isEqualTo("[{\"name\":\"Joe\"},{\"name\":\"Joseph\"}]"); + } + + + @RestController + static class TestController { + + @GetMapping("/person/{id}") + Person getPerson() { + return new Person("Joe"); + } + + @GetMapping("/persons") + List getPersons() { + return Arrays.asList(new Person("Joe"), new Person("Joseph")); + } + } +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/AsyncTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/AsyncTests.java index fa118ffabd55..77259b29648e 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/AsyncTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/AsyncTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -32,6 +32,7 @@ import org.springframework.util.concurrent.ListenableFuture; import org.springframework.util.concurrent.ListenableFutureTask; import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @@ -99,7 +100,7 @@ public void deferredResult() { } @Test - public void deferredResultWithImmediateValue() throws Exception { + public void deferredResultWithImmediateValue() { this.testClient.get() .uri("/1?deferredResultWithImmediateValue=true") .exchange() @@ -109,7 +110,7 @@ public void deferredResultWithImmediateValue() throws Exception { } @Test - public void deferredResultWithDelayedError() throws Exception { + public void deferredResultWithDelayedError() { this.testClient.get() .uri("/1?deferredResultWithDelayedError=true") .exchange() @@ -118,7 +119,7 @@ public void deferredResultWithDelayedError() throws Exception { } @Test - public void listenableFuture() throws Exception { + public void listenableFuture() { this.testClient.get() .uri("/1?listenableFuture=true") .exchange() @@ -142,17 +143,17 @@ public void completableFutureWithImmediateValue() throws Exception { @RequestMapping(path = "/{id}", produces = "application/json") private static class AsyncController { - @RequestMapping(params = "callable") + @GetMapping(params = "callable") public Callable getCallable() { return () -> new Person("Joe"); } - @RequestMapping(params = "streaming") + @GetMapping(params = "streaming") public StreamingResponseBody getStreaming() { return os -> os.write("name=Joe".getBytes(StandardCharsets.UTF_8)); } - @RequestMapping(params = "streamingSlow") + @GetMapping(params = "streamingSlow") public StreamingResponseBody getStreamingSlow() { return os -> { os.write("name=Joe".getBytes()); @@ -166,41 +167,41 @@ public StreamingResponseBody getStreamingSlow() { }; } - @RequestMapping(params = "streamingJson") + @GetMapping(params = "streamingJson") public ResponseEntity getStreamingJson() { return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON) .body(os -> os.write("{\"name\":\"Joe\",\"someDouble\":0.5}".getBytes(StandardCharsets.UTF_8))); } - @RequestMapping(params = "deferredResult") + @GetMapping(params = "deferredResult") public DeferredResult getDeferredResult() { DeferredResult result = new DeferredResult<>(); delay(100, () -> result.setResult(new Person("Joe"))); return result; } - @RequestMapping(params = "deferredResultWithImmediateValue") + @GetMapping(params = "deferredResultWithImmediateValue") public DeferredResult getDeferredResultWithImmediateValue() { DeferredResult result = new DeferredResult<>(); result.setResult(new Person("Joe")); return result; } - @RequestMapping(params = "deferredResultWithDelayedError") + @GetMapping(params = "deferredResultWithDelayedError") public DeferredResult getDeferredResultWithDelayedError() { DeferredResult result = new DeferredResult<>(); delay(100, () -> result.setErrorResult(new RuntimeException("Delayed Error"))); return result; } - @RequestMapping(params = "listenableFuture") + @GetMapping(params = "listenableFuture") public ListenableFuture getListenableFuture() { ListenableFutureTask futureTask = new ListenableFutureTask<>(() -> new Person("Joe")); delay(100, futureTask); return futureTask; } - @RequestMapping(params = "completableFutureWithImmediateValue") + @GetMapping(params = "completableFutureWithImmediateValue") public CompletableFuture getCompletableFutureWithImmediateValue() { CompletableFuture future = new CompletableFuture<>(); future.complete(new Person("Joe")); diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/SseTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/SseTests.java new file mode 100644 index 000000000000..a22cd2a1b86a --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/SseTests.java @@ -0,0 +1,71 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.test.web.servlet.samples.client.standalone; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +import org.springframework.test.web.Person; +import org.springframework.test.web.reactive.server.FluxExchangeResult; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.test.web.servlet.client.MockMvcWebTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import static java.time.Duration.ofMillis; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * SSE controller tests with MockMvc and WebTestClient. + * + * @author Rossen Stoyanchev + */ +public class SseTests { + + private final WebTestClient testClient = + MockMvcWebTestClient.bindToController(new SseController()).build(); + + + @Test + public void sse() { + FluxExchangeResult exchangeResult = this.testClient.get() + .uri("/persons") + .exchange() + .expectStatus().isOk() + .expectHeader().contentType("text/event-stream") + .returnResult(Person.class); + + StepVerifier.create(exchangeResult.getResponseBody()) + .expectNext(new Person("N0"), new Person("N1"), new Person("N2")) + .expectNextCount(4) + .consumeNextWith(person -> assertThat(person.getName()).endsWith("7")) + .thenCancel() + .verify(); + } + + + @RestController + private static class SseController { + + @GetMapping(path = "/persons", produces = "text/event-stream") + public Flux getPersonStream() { + return Flux.interval(ofMillis(100)).take(50).onBackpressureBuffer(50) + .map(index -> new Person("N" + index)); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/setup/StandaloneMockMvcBuilderTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/setup/StandaloneMockMvcBuilderTests.java index 7c31562e7c5e..d55800e1817b 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/setup/StandaloneMockMvcBuilderTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/setup/StandaloneMockMvcBuilderTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -49,10 +49,10 @@ * @author Rob Winch * @author Sebastien Deleuze */ -public class StandaloneMockMvcBuilderTests { +class StandaloneMockMvcBuilderTests { @Test // SPR-10825 - public void placeHoldersInRequestMapping() throws Exception { + void placeHoldersInRequestMapping() throws Exception { TestStandaloneMockMvcBuilder builder = new TestStandaloneMockMvcBuilder(new PlaceholderController()); builder.addPlaceholderValue("sys.login.ajax", "/foo"); builder.build(); @@ -68,7 +68,7 @@ public void placeHoldersInRequestMapping() throws Exception { @Test // SPR-13637 @SuppressWarnings("deprecation") - public void suffixPatternMatch() throws Exception { + void suffixPatternMatch() throws Exception { TestStandaloneMockMvcBuilder builder = new TestStandaloneMockMvcBuilder(new PersonController()); builder.setUseSuffixPatternMatch(false); builder.build(); @@ -86,7 +86,7 @@ public void suffixPatternMatch() throws Exception { } @Test // SPR-12553 - public void applicationContextAttribute() { + void applicationContextAttribute() { TestStandaloneMockMvcBuilder builder = new TestStandaloneMockMvcBuilder(new PlaceholderController()); builder.addPlaceholderValue("sys.login.ajax", "/foo"); WebApplicationContext wac = builder.initWebAppContext(); @@ -94,28 +94,28 @@ public void applicationContextAttribute() { } @Test - public void addFiltersFiltersNull() { + void addFiltersFiltersNull() { StandaloneMockMvcBuilder builder = MockMvcBuilders.standaloneSetup(new PersonController()); assertThatIllegalArgumentException().isThrownBy(() -> builder.addFilters((Filter[]) null)); } @Test - public void addFiltersFiltersContainsNull() { + void addFiltersFiltersContainsNull() { StandaloneMockMvcBuilder builder = MockMvcBuilders.standaloneSetup(new PersonController()); assertThatIllegalArgumentException().isThrownBy(() -> - builder.addFilters(new ContinueFilter(), (Filter) null)); + builder.addFilters(new ContinueFilter(), null)); } @Test - public void addFilterPatternsNull() { + void addFilterPatternsNull() { StandaloneMockMvcBuilder builder = MockMvcBuilders.standaloneSetup(new PersonController()); assertThatIllegalArgumentException().isThrownBy(() -> builder.addFilter(new ContinueFilter(), (String[]) null)); } @Test - public void addFilterPatternContainsNull() { + void addFilterPatternContainsNull() { StandaloneMockMvcBuilder builder = MockMvcBuilders.standaloneSetup(new PersonController()); assertThatIllegalArgumentException().isThrownBy(() -> builder.addFilter(new ContinueFilter(), (String) null)); @@ -123,7 +123,7 @@ public void addFilterPatternContainsNull() { @Test // SPR-13375 @SuppressWarnings("rawtypes") - public void springHandlerInstantiator() { + void springHandlerInstantiator() { TestStandaloneMockMvcBuilder builder = new TestStandaloneMockMvcBuilder(new PersonController()); builder.build(); SpringHandlerInstantiator instantiator = new SpringHandlerInstantiator(builder.wac.getAutowireCapableBeanFactory()); @@ -171,7 +171,7 @@ public String forward() { } - private class ContinueFilter extends OncePerRequestFilter { + private static class ContinueFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, diff --git a/spring-test/src/test/resources/log4j2-test.xml b/spring-test/src/test/resources/log4j2-test.xml index 3fb0b802d6a7..89d254091a47 100644 --- a/spring-test/src/test/resources/log4j2-test.xml +++ b/spring-test/src/test/resources/log4j2-test.xml @@ -25,6 +25,7 @@ + diff --git a/spring-tx/src/main/java/org/springframework/transaction/event/TransactionalApplicationListener.java b/spring-tx/src/main/java/org/springframework/transaction/event/TransactionalApplicationListener.java index 49939b6dcdf4..b90ed812ea5d 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/event/TransactionalApplicationListener.java +++ b/spring-tx/src/main/java/org/springframework/transaction/event/TransactionalApplicationListener.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -59,25 +59,27 @@ default int getOrder() { return Ordered.LOWEST_PRECEDENCE; } - /** - * Return the {@link TransactionPhase} in which the listener will be invoked. - *

    The default phase is {@link TransactionPhase#AFTER_COMMIT}. - */ - default TransactionPhase getTransactionPhase() { - return TransactionPhase.AFTER_COMMIT; - } - /** * Return an identifier for the listener to be able to refer to it individually. *

    It might be necessary for specific completion callback implementations * to provide a specific id, whereas for other scenarios an empty String * (as the common default value) is acceptable as well. + * @see org.springframework.context.event.SmartApplicationListener#getListenerId() + * @see TransactionalEventListener#id * @see #addCallback */ default String getListenerId() { return ""; } + /** + * Return the {@link TransactionPhase} in which the listener will be invoked. + *

    The default phase is {@link TransactionPhase#AFTER_COMMIT}. + */ + default TransactionPhase getTransactionPhase() { + return TransactionPhase.AFTER_COMMIT; + } + /** * Add a callback to be invoked on processing within transaction synchronization, * i.e. when {@link #processEvent} is being triggered during actual transactions. diff --git a/spring-tx/src/main/java/org/springframework/transaction/event/TransactionalApplicationListenerMethodAdapter.java b/spring-tx/src/main/java/org/springframework/transaction/event/TransactionalApplicationListenerMethodAdapter.java index d2014fa18e42..ce39e136c1a0 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/event/TransactionalApplicationListenerMethodAdapter.java +++ b/spring-tx/src/main/java/org/springframework/transaction/event/TransactionalApplicationListenerMethodAdapter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,11 +25,8 @@ import org.springframework.context.event.EventListener; import org.springframework.context.event.GenericApplicationListener; import org.springframework.core.annotation.AnnotatedElementUtils; -import org.springframework.lang.Nullable; import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.util.Assert; -import org.springframework.util.ClassUtils; -import org.springframework.util.StringUtils; /** * {@link GenericApplicationListener} adapter that delegates the processing of @@ -55,9 +52,6 @@ public class TransactionalApplicationListenerMethodAdapter extends ApplicationLi private final TransactionPhase transactionPhase; - @Nullable - private volatile String listenerId; - private final List callbacks = new CopyOnWriteArrayList<>(); @@ -84,31 +78,6 @@ public TransactionPhase getTransactionPhase() { return this.transactionPhase; } - @Override - public String getListenerId() { - String id = this.listenerId; - if (id == null) { - id = this.annotation.id(); - if (id.isEmpty()) { - id = getDefaultListenerId(); - } - this.listenerId = id; - } - return id; - } - - /** - * Determine the default id for the target listener, to be applied in case of - * no {@link TransactionalEventListener#id() annotation-specified id value}. - *

    The default implementation builds a method name with parameter types. - * @see #getListenerId() - */ - protected String getDefaultListenerId() { - Method method = getTargetMethod(); - return ClassUtils.getQualifiedMethodName(method) + - "(" + StringUtils.arrayToDelimitedString(method.getParameterTypes(), ",") + ")"; - } - @Override public void addCallback(SynchronizationCallback callback) { Assert.notNull(callback, "SynchronizationCallback must not be null"); diff --git a/spring-tx/src/main/java/org/springframework/transaction/event/TransactionalEventListener.java b/spring-tx/src/main/java/org/springframework/transaction/event/TransactionalEventListener.java index 992f9e4c39e8..20968245c91e 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/event/TransactionalEventListener.java +++ b/spring-tx/src/main/java/org/springframework/transaction/event/TransactionalEventListener.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -64,13 +64,6 @@ */ TransactionPhase phase() default TransactionPhase.AFTER_COMMIT; - /** - * An optional identifier to uniquely reference the listener. - * @since 5.3 - * @see TransactionalApplicationListener#getListenerId() - */ - String id() default ""; - /** * Whether the event should be processed if no transaction is running. */ @@ -98,6 +91,17 @@ *

    The default is {@code ""}, meaning the event is always handled. * @see EventListener#condition */ + @AliasFor(annotation = EventListener.class, attribute = "condition") String condition() default ""; + /** + * An optional identifier for the listener, defaulting to the fully-qualified + * signature of the declaring method (e.g. "mypackage.MyClass.myMethod()"). + * @since 5.3 + * @see EventListener#id + * @see TransactionalApplicationListener#getListenerId() + */ + @AliasFor(annotation = EventListener.class, attribute = "id") + String id() default ""; + } diff --git a/spring-tx/src/main/java/org/springframework/transaction/support/TransactionSynchronizationUtils.java b/spring-tx/src/main/java/org/springframework/transaction/support/TransactionSynchronizationUtils.java index 560a2e80fb46..8621d14988fb 100644 --- a/spring-tx/src/main/java/org/springframework/transaction/support/TransactionSynchronizationUtils.java +++ b/spring-tx/src/main/java/org/springframework/transaction/support/TransactionSynchronizationUtils.java @@ -59,7 +59,6 @@ public static boolean sameResourceFactory(ResourceTransactionManager tm, Object * the given handle as-is. * @since 5.3.4 * @see InfrastructureProxy#getWrappedObject() - * @see ScopedProxyUnwrapper#unwrapIfNecessary(Object) */ public static Object unwrapResourceIfNecessary(Object resource) { Assert.notNull(resource, "Resource must not be null"); diff --git a/spring-tx/src/test/java/org/springframework/transaction/event/TransactionalApplicationListenerMethodAdapterTests.java b/spring-tx/src/test/java/org/springframework/transaction/event/TransactionalApplicationListenerMethodAdapterTests.java index f8bb2384c9d4..6c66837caf58 100644 --- a/spring-tx/src/test/java/org/springframework/transaction/event/TransactionalApplicationListenerMethodAdapterTests.java +++ b/spring-tx/src/test/java/org/springframework/transaction/event/TransactionalApplicationListenerMethodAdapterTests.java @@ -26,6 +26,7 @@ import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.util.ClassUtils; import org.springframework.util.ReflectionUtils; import static org.assertj.core.api.Assertions.assertThat; @@ -81,7 +82,7 @@ public void invokesCompletionCallbackOnSuccess() { assertThat(callback.postEvent).isEqualTo(event); assertThat(callback.ex).isNull(); assertThat(adapter.getTransactionPhase()).isEqualTo(TransactionPhase.AFTER_COMMIT); - assertThat(adapter.getListenerId()).endsWith("SampleEvents.defaultPhase(class java.lang.String)"); + assertThat(adapter.getListenerId()).endsWith("SampleEvents.defaultPhase(java.lang.String)"); } @Test @@ -102,7 +103,7 @@ public void invokesExceptionHandlerOnException() { assertThat(callback.ex).isInstanceOf(RuntimeException.class); assertThat(callback.ex.getMessage()).isEqualTo("event"); assertThat(adapter.getTransactionPhase()).isEqualTo(TransactionPhase.BEFORE_COMMIT); - assertThat(adapter.getListenerId()).isEqualTo(adapter.getDefaultListenerId()); + assertThat(adapter.getListenerId()).isEqualTo(ClassUtils.getQualifiedMethodName(m) + "(java.lang.String)"); } @Test diff --git a/spring-web/spring-web.gradle b/spring-web/spring-web.gradle index 3745b5201bad..6d1ca73a86b2 100644 --- a/spring-web/spring-web.gradle +++ b/spring-web/spring-web.gradle @@ -7,6 +7,7 @@ dependencies { compile(project(":spring-beans")) compile(project(":spring-core")) compileOnly(project(":kotlin-coroutines")) + compileOnly("io.projectreactor.tools:blockhound") optional(project(":spring-aop")) optional(project(":spring-context")) optional(project(":spring-oxm")) @@ -75,6 +76,7 @@ dependencies { testCompile("org.skyscreamer:jsonassert") testCompile("org.xmlunit:xmlunit-assertj") testCompile("org.xmlunit:xmlunit-matchers") + testCompile("io.projectreactor.tools:blockhound") testRuntime("com.sun.mail:javax.mail") testRuntime("com.sun.xml.bind:jaxb-core") testRuntime("com.sun.xml.bind:jaxb-impl") diff --git a/spring-web/src/main/java/org/springframework/http/HttpHeaders.java b/spring-web/src/main/java/org/springframework/http/HttpHeaders.java index 54e07c9eca9b..b009471a26ff 100644 --- a/spring-web/src/main/java/org/springframework/http/HttpHeaders.java +++ b/spring-web/src/main/java/org/springframework/http/HttpHeaders.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -99,6 +99,12 @@ public class HttpHeaders implements MultiValueMap, Serializable * @see Section 5.3.5 of RFC 7231 */ public static final String ACCEPT_LANGUAGE = "Accept-Language"; + /** + * The HTTP {@code Accept-Patch} header field name. + * @since 5.3.6 + * @see Section 3.1 of RFC 5789 + */ + public static final String ACCEPT_PATCH = "Accept-Patch"; /** * The HTTP {@code Accept-Ranges} header field name. * @see Section 5.3.5 of RFC 7233 @@ -525,6 +531,25 @@ public List getAcceptLanguageAsLocales() { .collect(Collectors.toList()); } + /** + * Set the list of acceptable {@linkplain MediaType media types} for + * {@code PATCH} methods, as specified by the {@code Accept-Patch} header. + * @since 5.3.6 + */ + public void setAcceptPatch(List mediaTypes) { + set(ACCEPT_PATCH, MediaType.toString(mediaTypes)); + } + + /** + * Return the list of acceptable {@linkplain MediaType media types} for + * {@code PATCH} methods, as specified by the {@code Accept-Patch} header. + *

    Returns an empty list when the acceptable media types are unspecified. + * @since 5.3.6 + */ + public List getAcceptPatch() { + return MediaType.parseMediaTypes(get(ACCEPT_PATCH)); + } + /** * Set the (new) value of the {@code Access-Control-Allow-Credentials} response header. */ diff --git a/spring-web/src/main/java/org/springframework/http/MediaTypeFactory.java b/spring-web/src/main/java/org/springframework/http/MediaTypeFactory.java index 8487ab5b8868..b61cfea07350 100644 --- a/spring-web/src/main/java/org/springframework/http/MediaTypeFactory.java +++ b/spring-web/src/main/java/org/springframework/http/MediaTypeFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,6 +28,7 @@ import org.springframework.core.io.Resource; import org.springframework.lang.Nullable; +import org.springframework.util.Assert; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.util.StringUtils; @@ -65,6 +66,7 @@ private MediaTypeFactory() { */ private static MultiValueMap parseMimeTypes() { InputStream is = MediaTypeFactory.class.getResourceAsStream(MIME_TYPES_FILE_NAME); + Assert.state(is != null, MIME_TYPES_FILE_NAME + " not found in classpath"); try (BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.US_ASCII))) { MultiValueMap result = new LinkedMultiValueMap<>(); String line; @@ -82,7 +84,7 @@ private static MultiValueMap parseMimeTypes() { return result; } catch (IOException ex) { - throw new IllegalStateException("Could not load '" + MIME_TYPES_FILE_NAME + "'", ex); + throw new IllegalStateException("Could not read " + MIME_TYPES_FILE_NAME, ex); } } diff --git a/spring-web/src/main/java/org/springframework/http/ResponseEntity.java b/spring-web/src/main/java/org/springframework/http/ResponseEntity.java index 923d12f0e907..6fae98b2cb21 100644 --- a/spring-web/src/main/java/org/springframework/http/ResponseEntity.java +++ b/spring-web/src/main/java/org/springframework/http/ResponseEntity.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -82,7 +82,7 @@ public class ResponseEntity extends HttpEntity { /** - * Create an {@code ResponseEntity} with a status code only. + * Create a {@code ResponseEntity} with a status code only. * @param status the status code */ public ResponseEntity(HttpStatus status) { @@ -90,7 +90,7 @@ public ResponseEntity(HttpStatus status) { } /** - * Create an {@code ResponseEntity} with a body and status code. + * Create a {@code ResponseEntity} with a body and status code. * @param body the entity body * @param status the status code */ @@ -99,7 +99,7 @@ public ResponseEntity(@Nullable T body, HttpStatus status) { } /** - * Create an {@code HttpEntity} with headers and a status code. + * Create a {@code ResponseEntity} with headers and a status code. * @param headers the entity headers * @param status the status code */ @@ -108,7 +108,7 @@ public ResponseEntity(MultiValueMap headers, HttpStatus status) } /** - * Create an {@code HttpEntity} with a body, headers, and a status code. + * Create a {@code ResponseEntity} with a body, headers, and a status code. * @param body the entity body * @param headers the entity headers * @param status the status code @@ -118,7 +118,7 @@ public ResponseEntity(@Nullable T body, @Nullable MultiValueMap } /** - * Create an {@code HttpEntity} with a body, headers, and a raw status code. + * Create a {@code ResponseEntity} with a body, headers, and a raw status code. * @param body the entity body * @param headers the entity headers * @param rawStatus the status code value @@ -186,7 +186,7 @@ public int hashCode() { @Override public String toString() { StringBuilder builder = new StringBuilder("<"); - builder.append(this.status.toString()); + builder.append(this.status); if (this.status instanceof HttpStatus) { builder.append(' '); builder.append(((HttpStatus) this.status).getReasonPhrase()); @@ -237,12 +237,13 @@ public static BodyBuilder ok() { } /** - * A shortcut for creating a {@code ResponseEntity} with the given body and - * the status set to {@linkplain HttpStatus#OK OK}. + * A shortcut for creating a {@code ResponseEntity} with the given body + * and the status set to {@linkplain HttpStatus#OK OK}. + * @param body the body of the response entity (possibly empty) * @return the created {@code ResponseEntity} * @since 4.1 */ - public static ResponseEntity ok(T body) { + public static ResponseEntity ok(@Nullable T body) { return ok().body(body); } diff --git a/spring-web/src/main/java/org/springframework/http/client/reactive/ClientHttpResponse.java b/spring-web/src/main/java/org/springframework/http/client/reactive/ClientHttpResponse.java index 43e711635b63..ee2b8c3609c5 100644 --- a/spring-web/src/main/java/org/springframework/http/client/reactive/ClientHttpResponse.java +++ b/spring-web/src/main/java/org/springframework/http/client/reactive/ClientHttpResponse.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,6 +20,7 @@ import org.springframework.http.ReactiveHttpInputMessage; import org.springframework.http.ResponseCookie; import org.springframework.util.MultiValueMap; +import org.springframework.util.ObjectUtils; /** * Represents a client-side reactive HTTP response. @@ -30,6 +31,15 @@ */ public interface ClientHttpResponse extends ReactiveHttpInputMessage { + /** + * Return an id that represents the underlying connection, if available, + * or the request for the purpose of correlating log messages. + * @since 5.3.5 + */ + default String getId() { + return ObjectUtils.getIdentityHexString(this); + } + /** * Return the HTTP status code as an {@link HttpStatus} enum value. * @return the HTTP status as an HttpStatus enum value (never {@code null}) diff --git a/spring-web/src/main/java/org/springframework/http/client/reactive/ClientHttpResponseDecorator.java b/spring-web/src/main/java/org/springframework/http/client/reactive/ClientHttpResponseDecorator.java index 233ea5047647..389a42b3388a 100644 --- a/spring-web/src/main/java/org/springframework/http/client/reactive/ClientHttpResponseDecorator.java +++ b/spring-web/src/main/java/org/springframework/http/client/reactive/ClientHttpResponseDecorator.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -50,6 +50,11 @@ public ClientHttpResponse getDelegate() { // ClientHttpResponse delegation methods... + @Override + public String getId() { + return this.delegate.getId(); + } + @Override public HttpStatus getStatusCode() { return this.delegate.getStatusCode(); diff --git a/spring-web/src/main/java/org/springframework/http/client/reactive/ReactorClientHttpResponse.java b/spring-web/src/main/java/org/springframework/http/client/reactive/ReactorClientHttpResponse.java index a7249e20b0ac..26903c4481aa 100644 --- a/spring-web/src/main/java/org/springframework/http/client/reactive/ReactorClientHttpResponse.java +++ b/spring-web/src/main/java/org/springframework/http/client/reactive/ReactorClientHttpResponse.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -37,9 +37,11 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseCookie; import org.springframework.lang.Nullable; +import org.springframework.util.ClassUtils; import org.springframework.util.CollectionUtils; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; +import org.springframework.util.ObjectUtils; /** * {@link ClientHttpResponse} implementation for the Reactor-Netty HTTP client. @@ -51,6 +53,11 @@ */ class ReactorClientHttpResponse implements ClientHttpResponse { + /** Reactor Netty 1.0.5+. */ + static final boolean reactorNettyRequestChannelOperationsIdPresent = ClassUtils.isPresent( + "reactor.netty.ChannelOperationsId", ReactorClientHttpResponse.class.getClassLoader()); + + private static final Log logger = LogFactory.getLog(ReactorClientHttpResponse.class); private final HttpClientResponse response; @@ -64,8 +71,6 @@ class ReactorClientHttpResponse implements ClientHttpResponse { // 0 - not subscribed, 1 - subscribed, 2 - cancelled via connector (before subscribe) private final AtomicInteger state = new AtomicInteger(); - private final String logPrefix; - /** * Constructor that matches the inputs from @@ -78,7 +83,6 @@ public ReactorClientHttpResponse(HttpClientResponse response, Connection connect this.headers = HttpHeaders.readOnlyHttpHeaders(adapter); this.inbound = connection.inbound(); this.bufferFactory = new NettyDataBufferFactory(connection.outbound().alloc()); - this.logPrefix = (logger.isDebugEnabled() ? "[" + connection.channel().id().asShortText() + "] " : ""); } /** @@ -92,10 +96,21 @@ public ReactorClientHttpResponse(HttpClientResponse response, NettyInbound inbou this.headers = HttpHeaders.readOnlyHttpHeaders(adapter); this.inbound = inbound; this.bufferFactory = new NettyDataBufferFactory(alloc); - this.logPrefix = ""; } + @Override + public String getId() { + String id = null; + if (reactorNettyRequestChannelOperationsIdPresent) { + id = ChannelOperationsIdHelper.getId(this.response); + } + if (id == null && this.response instanceof Connection) { + id = ((Connection) this.response).channel().id().asShortText(); + } + return (id != null ? id : ObjectUtils.getIdentityHexString(this)); + } + @Override public Flux getBody() { return this.inbound.receive() @@ -167,7 +182,7 @@ private static String getSameSite(Cookie cookie) { void releaseAfterCancel(HttpMethod method) { if (mayHaveBody(method) && this.state.compareAndSet(0, 2)) { if (logger.isDebugEnabled()) { - logger.debug(this.logPrefix + "Releasing body, not yet subscribed."); + logger.debug("[" + getId() + "]" + "Releasing body, not yet subscribed."); } this.inbound.receive().doOnNext(byteBuf -> {}).subscribe(byteBuf -> {}, ex -> {}); } @@ -186,4 +201,18 @@ public String toString() { "status=" + getRawStatusCode() + '}'; } + + private static class ChannelOperationsIdHelper { + + @Nullable + public static String getId(HttpClientResponse response) { + if (response instanceof reactor.netty.ChannelOperationsId) { + return (logger.isDebugEnabled() ? + ((reactor.netty.ChannelOperationsId) response).asLongText() : + ((reactor.netty.ChannelOperationsId) response).asShortText()); + } + return null; + } + } + } diff --git a/spring-web/src/main/java/org/springframework/http/codec/ServerSentEventHttpMessageWriter.java b/spring-web/src/main/java/org/springframework/http/codec/ServerSentEventHttpMessageWriter.java index 6f54d9631b18..73befefb65b4 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/ServerSentEventHttpMessageWriter.java +++ b/spring-web/src/main/java/org/springframework/http/codec/ServerSentEventHttpMessageWriter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,7 +18,6 @@ import java.nio.charset.StandardCharsets; import java.time.Duration; -import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; @@ -171,12 +170,14 @@ private Flux encodeEvent(StringBuilder eventContent, T data, Res if (this.encoder == null) { throw new CodecException("No SSE encoder configured and the data is not String."); } - DataBuffer buffer = ((Encoder) this.encoder).encodeValue(data, factory, dataType, mediaType, hints); - Hints.touchDataBuffer(buffer, hints, logger); - return Flux.just(factory.join(Arrays.asList( - encodeText(eventContent, mediaType, factory), - buffer, - encodeText("\n\n", mediaType, factory)))); + + return Flux.defer(() -> { + DataBuffer startBuffer = encodeText(eventContent, mediaType, factory); + DataBuffer endBuffer = encodeText("\n\n", mediaType, factory); + DataBuffer dataBuffer = ((Encoder) this.encoder).encodeValue(data, factory, dataType, mediaType, hints); + Hints.touchDataBuffer(dataBuffer, hints, logger); + return Flux.just(startBuffer, dataBuffer, endBuffer); + }); } private void writeField(String fieldName, Object fieldValue, StringBuilder sb) { diff --git a/spring-web/src/main/java/org/springframework/http/codec/multipart/DefaultPartHttpMessageReader.java b/spring-web/src/main/java/org/springframework/http/codec/multipart/DefaultPartHttpMessageReader.java index 175c7cdbedde..64c465035241 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/multipart/DefaultPartHttpMessageReader.java +++ b/spring-web/src/main/java/org/springframework/http/codec/multipart/DefaultPartHttpMessageReader.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,6 +17,7 @@ package org.springframework.http.codec.multipart; import java.io.IOException; +import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -78,6 +79,8 @@ public class DefaultPartHttpMessageReader extends LoggingCodecSupport implements private Mono fileStorageDirectory = Mono.defer(this::defaultFileStorageDirectory).cache(); + private Charset headersCharset = StandardCharsets.UTF_8; + /** * Configure the maximum amount of memory that is allowed per headers section of each part. @@ -132,7 +135,7 @@ public void setMaxParts(int maxParts) { } /** - * Sets the directory used to store parts larger than + * Set the directory used to store parts larger than * {@link #setMaxInMemorySize(int) maxInMemorySize}. By default, a directory * named {@code spring-webflux-multipart} is created under the system * temporary directory. @@ -151,7 +154,7 @@ public void setFileStorageDirectory(Path fileStorageDirectory) throws IOExceptio } /** - * Sets the Reactor {@link Scheduler} to be used for creating files and + * Set the Reactor {@link Scheduler} to be used for creating files and * directories, and writing to files. By default, * {@link Schedulers#boundedElastic()} is used, but this property allows for * changing it to an externally managed scheduler. @@ -171,13 +174,11 @@ public void setBlockingOperationScheduler(Scheduler blockingOperationScheduler) * in memory nor file. * When {@code false}, parts are backed by * in-memory and/or file storage. Defaults to {@code false}. - * *

    NOTE that with streaming enabled, the * {@code Flux} that is produced by this message reader must be * consumed in the original order, i.e. the order of the HTTP message. * Additionally, the {@linkplain Part#content() body contents} must either * be completely consumed or canceled before moving to the next part. - * *

    Also note that enabling this property effectively ignores * {@link #setMaxInMemorySize(int) maxInMemorySize}, * {@link #setMaxDiskUsagePerPart(long) maxDiskUsagePerPart}, @@ -188,6 +189,18 @@ public void setStreaming(boolean streaming) { this.streaming = streaming; } + /** + * Set the character set used to decode headers. + * Defaults to UTF-8 as per RFC 7578. + * @param headersCharset the charset to use for decoding headers + * @since 5.3.6 + * @see RFC-7578 Section 5.2 + */ + public void setHeadersCharset(Charset headersCharset) { + Assert.notNull(headersCharset, "HeadersCharset must not be null"); + this.headersCharset = headersCharset; + } + @Override public List getReadableMediaTypes() { return Collections.singletonList(MediaType.MULTIPART_FORM_DATA); @@ -214,7 +227,7 @@ public Flux read(ResolvableType elementType, ReactiveHttpInputMessage mess message.getHeaders().getContentType() + "\"")); } Flux tokens = MultipartParser.parse(message.getBody(), boundary, - this.maxHeadersSize); + this.maxHeadersSize, this.headersCharset); return PartGenerator.createParts(tokens, this.maxParts, this.maxInMemorySize, this.maxDiskUsagePerPart, this.streaming, this.fileStorageDirectory, this.blockingOperationScheduler); @@ -222,12 +235,16 @@ public Flux read(ResolvableType elementType, ReactiveHttpInputMessage mess } @Nullable - private static byte[] boundary(HttpMessage message) { + private byte[] boundary(HttpMessage message) { MediaType contentType = message.getHeaders().getContentType(); if (contentType != null) { String boundary = contentType.getParameter("boundary"); if (boundary != null) { - return boundary.getBytes(StandardCharsets.ISO_8859_1); + int len = boundary.length(); + if (len > 2 && boundary.charAt(0) == '"' && boundary.charAt(len - 1) == '"') { + boundary = boundary.substring(1, len - 1); + } + return boundary.getBytes(this.headersCharset); } } return null; diff --git a/spring-web/src/main/java/org/springframework/http/codec/multipart/MultipartParser.java b/spring-web/src/main/java/org/springframework/http/codec/multipart/MultipartParser.java index deed6c327964..3d9ab7f2b3b6 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/multipart/MultipartParser.java +++ b/spring-web/src/main/java/org/springframework/http/codec/multipart/MultipartParser.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,7 +16,7 @@ package org.springframework.http.codec.multipart; -import java.nio.charset.StandardCharsets; +import java.nio.charset.Charset; import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; @@ -69,11 +69,14 @@ final class MultipartParser extends BaseSubscriber { private final AtomicBoolean requestOutstanding = new AtomicBoolean(); + private final Charset headersCharset; - private MultipartParser(FluxSink sink, byte[] boundary, int maxHeadersSize) { + + private MultipartParser(FluxSink sink, byte[] boundary, int maxHeadersSize, Charset headersCharset) { this.sink = sink; this.boundary = boundary; this.maxHeadersSize = maxHeadersSize; + this.headersCharset = headersCharset; this.state = new AtomicReference<>(new PreambleState()); } @@ -82,11 +85,13 @@ private MultipartParser(FluxSink sink, byte[] boundary, int maxHeadersSiz * @param buffers the input buffers * @param boundary the multipart boundary, as found in the {@code Content-Type} header * @param maxHeadersSize the maximum buffered header size + * @param headersCharset the charset to use for decoding headers * @return a stream of parsed tokens */ - public static Flux parse(Flux buffers, byte[] boundary, int maxHeadersSize) { + public static Flux parse(Flux buffers, byte[] boundary, int maxHeadersSize, + Charset headersCharset) { return Flux.create(sink -> { - MultipartParser parser = new MultipartParser(sink, boundary, maxHeadersSize); + MultipartParser parser = new MultipartParser(sink, boundary, maxHeadersSize, headersCharset); sink.onCancel(parser::onSinkCancel); sink.onRequest(n -> parser.requestBuffer()); buffers.subscribe(parser); @@ -180,7 +185,7 @@ private void requestBuffer() { /** - * Represents the output of {@link #parse(Flux, byte[], int)}. + * Represents the output of {@link #parse(Flux, byte[], int, Charset)}. */ public abstract static class Token { @@ -372,7 +377,6 @@ else if (count > MultipartParser.this.maxHeadersSize) { DataBufferUtils.release(buf); emitHeaders(parseHeaders()); - // TODO: no need to check result of changeState, no further statements changeState(this, new BodyState(), bodyBuf); } else { @@ -408,7 +412,7 @@ private HttpHeaders parseHeaders() { } DataBuffer joined = this.buffers.get(0).factory().join(this.buffers); this.buffers.clear(); - String string = joined.toString(StandardCharsets.ISO_8859_1); + String string = joined.toString(MultipartParser.this.headersCharset); DataBufferUtils.release(joined); String[] lines = string.split(HEADER_ENTRY_SEPARATOR); HttpHeaders result = new HttpHeaders(); diff --git a/spring-web/src/main/java/org/springframework/http/codec/multipart/PartGenerator.java b/spring-web/src/main/java/org/springframework/http/codec/multipart/PartGenerator.java index 39aa9149faa3..3e684a47fb23 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/multipart/PartGenerator.java +++ b/spring-web/src/main/java/org/springframework/http/codec/multipart/PartGenerator.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,6 +20,7 @@ import java.io.UncheckedIOException; import java.nio.ByteBuffer; import java.nio.channels.WritableByteChannel; +import java.nio.charset.Charset; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; @@ -53,7 +54,7 @@ /** * Subscribes to a token stream (i.e. the result of - * {@link MultipartParser#parse(Flux, byte[], int)}, and produces a flux of {@link Part} objects. + * {@link MultipartParser#parse(Flux, byte[], int, Charset)}, and produces a flux of {@link Part} objects. * * @author Arjen Poutsma * @since 5.3 @@ -577,6 +578,9 @@ public void createFile() { private WritingFileState createFileState(Path directory) { try { + if (!Files.exists(directory)) { + Files.createDirectory(directory); + } Path tempFile = Files.createTempFile(directory, null, ".multipart"); if (logger.isTraceEnabled()) { logger.trace("Storing multipart data in file " + tempFile); diff --git a/spring-web/src/main/java/org/springframework/http/codec/xml/Jaxb2XmlEncoder.java b/spring-web/src/main/java/org/springframework/http/codec/xml/Jaxb2XmlEncoder.java index 150ad691f2e4..b29b610bb518 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/xml/Jaxb2XmlEncoder.java +++ b/spring-web/src/main/java/org/springframework/http/codec/xml/Jaxb2XmlEncoder.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -39,6 +39,7 @@ import org.springframework.core.io.buffer.DataBufferFactory; import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.core.log.LogFormatUtils; +import org.springframework.http.MediaType; import org.springframework.lang.Nullable; import org.springframework.util.ClassUtils; import org.springframework.util.MimeType; @@ -64,7 +65,7 @@ public class Jaxb2XmlEncoder extends AbstractSingleValueEncoder { public Jaxb2XmlEncoder() { - super(MimeTypeUtils.APPLICATION_XML, MimeTypeUtils.TEXT_XML); + super(MimeTypeUtils.APPLICATION_XML, MimeTypeUtils.TEXT_XML, new MediaType("application", "*+xml")); } diff --git a/spring-web/src/main/java/org/springframework/http/converter/json/AbstractJackson2HttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/json/AbstractJackson2HttpMessageConverter.java index d6f5b49f6cfe..19b22fef2c81 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/json/AbstractJackson2HttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/json/AbstractJackson2HttpMessageConverter.java @@ -357,7 +357,9 @@ private Object readJavaType(JavaType javaType, HttpInputMessage inputMessage) th ObjectMapper objectMapper = selectObjectMapper(javaType.getRawClass(), contentType); Assert.state(objectMapper != null, "No ObjectMapper for " + javaType); - boolean isUnicode = ENCODINGS.containsKey(charset.name()); + boolean isUnicode = ENCODINGS.containsKey(charset.name()) || + "UTF-16".equals(charset.name()) || + "UTF-32".equals(charset.name()); try { if (inputMessage instanceof MappingJacksonInputMessage) { Class deserializationView = ((MappingJacksonInputMessage) inputMessage).getDeserializationView(); diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/AbstractListenerReadPublisher.java b/spring-web/src/main/java/org/springframework/http/server/reactive/AbstractListenerReadPublisher.java index b28b6e47a05e..a432dc7a7809 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/AbstractListenerReadPublisher.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/AbstractListenerReadPublisher.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -123,18 +123,22 @@ public final void onDataAvailable() { * all data has been read. */ public void onAllDataRead() { - rsReadLogger.trace(getLogPrefix() + "onAllDataRead"); - this.state.get().onAllDataRead(this); + State state = this.state.get(); + if (rsReadLogger.isTraceEnabled()) { + rsReadLogger.trace(getLogPrefix() + "onAllDataRead [" + state + "]"); + } + state.onAllDataRead(this); } /** * Sub-classes can call this to delegate container error notifications. */ public final void onError(Throwable ex) { + State state = this.state.get(); if (rsReadLogger.isTraceEnabled()) { - rsReadLogger.trace(getLogPrefix() + "Connection error: " + ex); + rsReadLogger.trace(getLogPrefix() + "onError: " + ex + " [" + state + "]"); } - this.state.get().onError(this, ex); + state.onError(this, ex); } @@ -191,13 +195,13 @@ private boolean readAndPublish() throws IOException { Subscriber subscriber = this.subscriber; Assert.state(subscriber != null, "No subscriber"); if (rsReadLogger.isTraceEnabled()) { - rsReadLogger.trace(getLogPrefix() + "Publishing data read"); + rsReadLogger.trace(getLogPrefix() + "Publishing " + data.getClass().getSimpleName()); } subscriber.onNext(data); } else { if (rsReadLogger.isTraceEnabled()) { - rsReadLogger.trace(getLogPrefix() + "No more data to read"); + rsReadLogger.trace(getLogPrefix() + "No more to read"); } return true; } @@ -255,17 +259,18 @@ private final class ReadSubscription implements Subscription { @Override public final void request(long n) { if (rsReadLogger.isTraceEnabled()) { - rsReadLogger.trace(getLogPrefix() + n + " requested"); + rsReadLogger.trace(getLogPrefix() + "request " + (n != Long.MAX_VALUE ? n : "Long.MAX_VALUE")); } state.get().request(AbstractListenerReadPublisher.this, n); } @Override public final void cancel() { + State state = AbstractListenerReadPublisher.this.state.get(); if (rsReadLogger.isTraceEnabled()) { - rsReadLogger.trace(getLogPrefix() + "Cancellation"); + rsReadLogger.trace(getLogPrefix() + "cancel [" + state + "]"); } - state.get().cancel(AbstractListenerReadPublisher.this); + state.cancel(AbstractListenerReadPublisher.this); } } diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/AbstractListenerWriteFlushProcessor.java b/spring-web/src/main/java/org/springframework/http/server/reactive/AbstractListenerWriteFlushProcessor.java index f8575ab31590..10342d681d10 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/AbstractListenerWriteFlushProcessor.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/AbstractListenerWriteFlushProcessor.java @@ -60,6 +60,9 @@ public abstract class AbstractListenerWriteFlushProcessor implements Processo private volatile boolean sourceCompleted; + @Nullable + private volatile AbstractListenerWriteProcessor currentWriteProcessor; + private final WriteResultPublisher resultPublisher; private final String logPrefix; @@ -75,7 +78,21 @@ public AbstractListenerWriteFlushProcessor() { */ public AbstractListenerWriteFlushProcessor(String logPrefix) { this.logPrefix = logPrefix; - this.resultPublisher = new WriteResultPublisher(logPrefix); + this.resultPublisher = new WriteResultPublisher(logPrefix + "[WFP] ", + () -> { + cancel(); + // Complete immediately + State oldState = this.state.getAndSet(State.COMPLETED); + if (rsWriteFlushLogger.isTraceEnabled()) { + rsWriteFlushLogger.trace(getLogPrefix() + oldState + " -> " + this.state); + } + // Propagate to current "write" Processor + AbstractListenerWriteProcessor writeProcessor = this.currentWriteProcessor; + if (writeProcessor != null) { + writeProcessor.cancelAndSetCompleted(); + } + this.currentWriteProcessor = null; + }); } @@ -98,7 +115,7 @@ public final void onSubscribe(Subscription subscription) { @Override public final void onNext(Publisher publisher) { if (rsWriteFlushLogger.isTraceEnabled()) { - rsWriteFlushLogger.trace(getLogPrefix() + "Received onNext publisher"); + rsWriteFlushLogger.trace(getLogPrefix() + "onNext: \"write\" Publisher"); } this.state.get().onNext(this, publisher); } @@ -109,10 +126,11 @@ public final void onNext(Publisher publisher) { */ @Override public final void onError(Throwable ex) { + State state = this.state.get(); if (rsWriteFlushLogger.isTraceEnabled()) { - rsWriteFlushLogger.trace(getLogPrefix() + "Received onError: " + ex); + rsWriteFlushLogger.trace(getLogPrefix() + "onError: " + ex + " [" + state + "]"); } - this.state.get().onError(this, ex); + state.onError(this, ex); } /** @@ -121,10 +139,11 @@ public final void onError(Throwable ex) { */ @Override public final void onComplete() { + State state = this.state.get(); if (rsWriteFlushLogger.isTraceEnabled()) { - rsWriteFlushLogger.trace(getLogPrefix() + "Received onComplete"); + rsWriteFlushLogger.trace(getLogPrefix() + "onComplete [" + state + "]"); } - this.state.get().onComplete(this); + state.onComplete(this); } /** @@ -137,12 +156,15 @@ protected final void onFlushPossible() { } /** - * Invoked during an error or completion callback from the underlying - * container to cancel the upstream subscription. + * Cancel the upstream chain of "write" Publishers only, for example due to + * Servlet container error/completion notifications. This should usually + * be followed up with a call to either {@link #onError(Throwable)} or + * {@link #onComplete()} to notify the downstream chain, that is unless + * cancellation came from downstream. */ protected void cancel() { if (rsWriteFlushLogger.isTraceEnabled()) { - rsWriteFlushLogger.trace(getLogPrefix() + "Received request to cancel"); + rsWriteFlushLogger.trace(getLogPrefix() + "cancel [" + this.state + "]"); } if (this.subscription != null) { this.subscription.cancel(); @@ -266,9 +288,10 @@ public void onNext(AbstractListenerWriteFlushProcessor processor, Publisher currentPublisher) { if (processor.changeState(this, RECEIVED)) { - Processor currentProcessor = processor.createWriteProcessor(); - currentPublisher.subscribe(currentProcessor); - currentProcessor.subscribe(new WriteResultSubscriber(processor)); + Processor writeProcessor = processor.createWriteProcessor(); + processor.currentWriteProcessor = (AbstractListenerWriteProcessor) writeProcessor; + currentPublisher.subscribe(writeProcessor); + writeProcessor.subscribe(new WriteResultSubscriber(processor)); } } @Override @@ -294,7 +317,7 @@ public void writeComplete(AbstractListenerWriteFlushProcessor processor) } if (processor.changeState(this, REQUESTED)) { if (processor.sourceCompleted) { - handleSubscriberCompleted(processor); + handleSourceCompleted(processor); } else { Assert.state(processor.subscription != null, "No subscription"); @@ -307,11 +330,11 @@ public void onComplete(AbstractListenerWriteFlushProcessor processor) { processor.sourceCompleted = true; // A competing write might have completed very quickly if (processor.state.get().equals(State.REQUESTED)) { - handleSubscriberCompleted(processor); + handleSourceCompleted(processor); } } - private void handleSubscriberCompleted(AbstractListenerWriteFlushProcessor processor) { + private void handleSourceCompleted(AbstractListenerWriteFlushProcessor processor) { if (processor.isFlushPending()) { // Ensure the final flush processor.changeState(State.REQUESTED, State.FLUSHING); @@ -423,6 +446,11 @@ public void onNext(Void aVoid) { @Override public void onError(Throwable ex) { + if (rsWriteFlushLogger.isTraceEnabled()) { + rsWriteFlushLogger.trace( + this.processor.getLogPrefix() + "current \"write\" Publisher failed: " + ex); + } + this.processor.currentWriteProcessor = null; this.processor.cancel(); this.processor.onError(ex); } @@ -430,10 +458,17 @@ public void onError(Throwable ex) { @Override public void onComplete() { if (rsWriteFlushLogger.isTraceEnabled()) { - rsWriteFlushLogger.trace(this.processor.getLogPrefix() + this.processor.state + " writeComplete"); + rsWriteFlushLogger.trace( + this.processor.getLogPrefix() + "current \"write\" Publisher completed"); } + this.processor.currentWriteProcessor = null; this.processor.state.get().writeComplete(this.processor); } + + @Override + public String toString() { + return this.processor.getClass().getSimpleName() + "-WriteResultSubscriber"; + } } } diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/AbstractListenerWriteProcessor.java b/spring-web/src/main/java/org/springframework/http/server/reactive/AbstractListenerWriteProcessor.java index 19c478554f24..6cfd8412a622 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/AbstractListenerWriteProcessor.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/AbstractListenerWriteProcessor.java @@ -88,8 +88,10 @@ public AbstractListenerWriteProcessor() { * @since 5.1 */ public AbstractListenerWriteProcessor(String logPrefix) { + // AbstractListenerFlushProcessor calls cancelAndSetCompleted directly, so this cancel task + // won't be used for HTTP responses, but it can be for a WebSocket session. + this.resultPublisher = new WriteResultPublisher(logPrefix + "[WP] ", this::cancelAndSetCompleted); this.logPrefix = (StringUtils.hasText(logPrefix) ? logPrefix : ""); - this.resultPublisher = new WriteResultPublisher(logPrefix); } @@ -112,7 +114,7 @@ public final void onSubscribe(Subscription subscription) { @Override public final void onNext(T data) { if (rsWriteLogger.isTraceEnabled()) { - rsWriteLogger.trace(getLogPrefix() + "Item to write"); + rsWriteLogger.trace(getLogPrefix() + "onNext: " + data.getClass().getSimpleName()); } this.state.get().onNext(this, data); } @@ -123,10 +125,11 @@ public final void onNext(T data) { */ @Override public final void onError(Throwable ex) { + State state = this.state.get(); if (rsWriteLogger.isTraceEnabled()) { - rsWriteLogger.trace(getLogPrefix() + "Write source error: " + ex); + rsWriteLogger.trace(getLogPrefix() + "onError: " + ex + " [" + state + "]"); } - this.state.get().onError(this, ex); + state.onError(this, ex); } /** @@ -135,10 +138,11 @@ public final void onError(Throwable ex) { */ @Override public final void onComplete() { + State state = this.state.get(); if (rsWriteLogger.isTraceEnabled()) { - rsWriteLogger.trace(getLogPrefix() + "No more items to write"); + rsWriteLogger.trace(getLogPrefix() + "onComplete [" + state + "]"); } - this.state.get().onComplete(this); + state.onComplete(this); } /** @@ -154,23 +158,49 @@ public final void onWritePossible() { } /** - * Invoked during an error or completion callback from the underlying - * container to cancel the upstream subscription. + * Cancel the upstream "write" Publisher only, for example due to + * Servlet container error/completion notifications. This should usually + * be followed up with a call to either {@link #onError(Throwable)} or + * {@link #onComplete()} to notify the downstream chain, that is unless + * cancellation came from downstream. */ public void cancel() { - rsWriteLogger.trace(getLogPrefix() + "Cancellation"); + if (rsWriteLogger.isTraceEnabled()) { + rsWriteLogger.trace(getLogPrefix() + "cancel [" + this.state + "]"); + } if (this.subscription != null) { this.subscription.cancel(); } } + /** + * Cancel the "write" Publisher and transition to COMPLETED immediately also + * without notifying the downstream. For use when cancellation came from + * downstream. + */ + void cancelAndSetCompleted() { + cancel(); + for (;;) { + State prev = this.state.get(); + if (prev.equals(State.COMPLETED)) { + break; + } + if (this.state.compareAndSet(prev, State.COMPLETED)) { + if (rsWriteLogger.isTraceEnabled()) { + rsWriteLogger.trace(getLogPrefix() + prev + " -> " + this.state); + } + if (!prev.equals(State.WRITING)) { + discardCurrentData(); + } + break; + } + } + } + // Publisher implementation for result notifications... @Override public final void subscribe(Subscriber subscriber) { - // Technically, cancellation from the result subscriber should be propagated - // to the upstream subscription. In practice, HttpHandler server adapters - // don't have a reason to cancel the result subscription. this.resultPublisher.subscribe(subscriber); } @@ -287,7 +317,7 @@ private void changeStateToComplete(State oldState) { private void writeIfPossible() { boolean result = isWritePossible(); if (!result && rsWriteLogger.isTraceEnabled()) { - rsWriteLogger.trace(getLogPrefix() + "isWritePossible: false"); + rsWriteLogger.trace(getLogPrefix() + "isWritePossible false"); } if (result) { onWritePossible(); diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/DefaultServerHttpRequestBuilder.java b/spring-web/src/main/java/org/springframework/http/server/reactive/DefaultServerHttpRequestBuilder.java index 847669e0eb65..d4398d76091b 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/DefaultServerHttpRequestBuilder.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/DefaultServerHttpRequestBuilder.java @@ -38,6 +38,7 @@ * * @author Rossen Stoyanchev * @author Sebastien Deleuze + * @author Brian Clozel * @since 5.0 */ class DefaultServerHttpRequestBuilder implements ServerHttpRequest.Builder { @@ -131,7 +132,7 @@ public ServerHttpRequest.Builder remoteAddress(InetSocketAddress remoteAddress) @Override public ServerHttpRequest build() { return new MutatedServerHttpRequest(getUriToUse(), this.contextPath, - this.httpMethodValue, this.sslInfo, this.remoteAddress, this.body, this.originalRequest); + this.httpMethodValue, this.sslInfo, this.remoteAddress, this.headers, this.body, this.originalRequest); } private URI getUriToUse() { @@ -190,9 +191,9 @@ private static class MutatedServerHttpRequest extends AbstractServerHttpRequest public MutatedServerHttpRequest(URI uri, @Nullable String contextPath, String methodValue, @Nullable SslInfo sslInfo, @Nullable InetSocketAddress remoteAddress, - Flux body, ServerHttpRequest originalRequest) { + HttpHeaders headers, Flux body, ServerHttpRequest originalRequest) { - super(uri, contextPath, originalRequest.getHeaders()); + super(uri, contextPath, headers); this.methodValue = methodValue; this.remoteAddress = (remoteAddress != null ? remoteAddress : originalRequest.getRemoteAddress()); this.sslInfo = (sslInfo != null ? sslInfo : originalRequest.getSslInfo()); diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/ReactorServerHttpRequest.java b/spring-web/src/main/java/org/springframework/http/server/reactive/ReactorServerHttpRequest.java index 79ae19495e32..35db4de36c87 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/ReactorServerHttpRequest.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/ReactorServerHttpRequest.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -27,6 +27,7 @@ import io.netty.handler.codec.http.HttpHeaderNames; import io.netty.handler.codec.http.cookie.Cookie; import io.netty.handler.ssl.SslHandler; +import org.apache.commons.logging.Log; import reactor.core.publisher.Flux; import reactor.netty.Connection; import reactor.netty.http.server.HttpServerRequest; @@ -34,8 +35,10 @@ import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.NettyDataBufferFactory; import org.springframework.http.HttpCookie; +import org.springframework.http.HttpLogging; import org.springframework.lang.Nullable; import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; @@ -48,6 +51,13 @@ */ class ReactorServerHttpRequest extends AbstractServerHttpRequest { + /** Reactor Netty 1.0.5+. */ + static final boolean reactorNettyRequestChannelOperationsIdPresent = ClassUtils.isPresent( + "reactor.netty.ChannelOperationsId", ReactorServerHttpRequest.class.getClassLoader()); + + private static final Log logger = HttpLogging.forLogName(ReactorServerHttpRequest.class); + + private static final AtomicLong logPrefixIndex = new AtomicLong(); @@ -187,6 +197,9 @@ public T getNativeRequest() { @Override @Nullable protected String initId() { + if (reactorNettyRequestChannelOperationsIdPresent) { + return (ChannelOperationsIdHelper.getId(this.request)); + } if (this.request instanceof Connection) { return ((Connection) this.request).channel().id().asShortText() + "-" + logPrefixIndex.incrementAndGet(); @@ -194,4 +207,18 @@ protected String initId() { return null; } + + private static class ChannelOperationsIdHelper { + + @Nullable + public static String getId(HttpServerRequest request) { + if (request instanceof reactor.netty.ChannelOperationsId) { + return (logger.isDebugEnabled() ? + ((reactor.netty.ChannelOperationsId) request).asLongText() : + ((reactor.netty.ChannelOperationsId) request).asShortText()); + } + return null; + } + } + } diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/ReactorServerHttpResponse.java b/spring-web/src/main/java/org/springframework/http/server/reactive/ReactorServerHttpResponse.java index ccfa1f1f8839..de8fd4121ccd 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/ReactorServerHttpResponse.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/ReactorServerHttpResponse.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,6 +26,7 @@ import org.reactivestreams.Publisher; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import reactor.netty.ChannelOperationsId; import reactor.netty.http.server.HttpServerResponse; import org.springframework.core.io.buffer.DataBuffer; @@ -125,6 +126,11 @@ private Publisher toByteBufs(Publisher dataBuffer @Override protected void touchDataBuffer(DataBuffer buffer) { if (logger.isDebugEnabled()) { + if (ReactorServerHttpRequest.reactorNettyRequestChannelOperationsIdPresent) { + if (ChannelOperationsIdHelper.touch(buffer, this.response)) { + return; + } + } this.response.withConnection(connection -> { ChannelId id = connection.channel().id(); DataBufferUtils.touch(buffer, "Channel id: " + id.asShortText()); @@ -132,4 +138,18 @@ protected void touchDataBuffer(DataBuffer buffer) { } } + + private static class ChannelOperationsIdHelper { + + public static boolean touch(DataBuffer dataBuffer, HttpServerResponse response) { + if (response instanceof reactor.netty.ChannelOperationsId) { + String id = ((ChannelOperationsId) response).asLongText(); + DataBufferUtils.touch(dataBuffer, "Channel id: " + id); + return true; + } + return false; + } + } + + } diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/ServletHttpHandlerAdapter.java b/spring-web/src/main/java/org/springframework/http/server/reactive/ServletHttpHandlerAdapter.java index 54961a8449de..b705df0da388 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/ServletHttpHandlerAdapter.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/ServletHttpHandlerAdapter.java @@ -167,8 +167,12 @@ public void service(ServletRequest request, ServletResponse response) throws Ser asyncContext.setTimeout(-1); ServletServerHttpRequest httpRequest; + AsyncListener requestListener; + String logPrefix; try { httpRequest = createRequest(((HttpServletRequest) request), asyncContext); + requestListener = httpRequest.getAsyncListener(); + logPrefix = httpRequest.getLogPrefix(); } catch (URISyntaxException ex) { if (logger.isDebugEnabled()) { @@ -180,15 +184,17 @@ public void service(ServletRequest request, ServletResponse response) throws Ser } ServerHttpResponse httpResponse = createResponse(((HttpServletResponse) response), asyncContext, httpRequest); + AsyncListener responseListener = ((ServletServerHttpResponse) httpResponse).getAsyncListener(); if (httpRequest.getMethod() == HttpMethod.HEAD) { httpResponse = new HttpHeadResponseDecorator(httpResponse); } - AtomicBoolean isCompleted = new AtomicBoolean(); - HandlerResultAsyncListener listener = new HandlerResultAsyncListener(isCompleted, httpRequest); - asyncContext.addListener(listener); + AtomicBoolean completionFlag = new AtomicBoolean(); + HandlerResultSubscriber subscriber = new HandlerResultSubscriber(asyncContext, completionFlag, logPrefix); + + asyncContext.addListener(new HttpHandlerAsyncListener( + requestListener, responseListener, subscriber, completionFlag, logPrefix)); - HandlerResultSubscriber subscriber = new HandlerResultSubscriber(asyncContext, isCompleted, httpRequest); this.httpHandler.handle(httpRequest, httpResponse).subscribe(subscriber); } @@ -222,10 +228,6 @@ public void destroy() { } - /** - * We cannot combine ERROR_LISTENER and HandlerResultSubscriber due to: - * https://issues.jboss.org/browse/WFLY-8515. - */ private static void runIfAsyncNotComplete(AsyncContext asyncContext, AtomicBoolean isCompleted, Runnable task) { try { if (asyncContext.getRequest().isAsyncStarted() && isCompleted.compareAndSet(false, true)) { @@ -248,62 +250,129 @@ private static void runIfAsyncNotComplete(AsyncContext asyncContext, AtomicBoole * cancel the write Publisher and signal onError/onComplete downstream to * the writing result Subscriber. */ - private static class HandlerResultAsyncListener implements AsyncListener { + private static class HttpHandlerAsyncListener implements AsyncListener { + + private final AsyncListener requestAsyncListener; + + private final AsyncListener responseAsyncListener; + + // We cannot have AsyncListener and HandlerResultSubscriber until WildFly 12+: + // https://issues.jboss.org/browse/WFLY-8515 + private final Runnable handlerDisposeTask; - private final AtomicBoolean isCompleted; + private final AtomicBoolean completionFlag; private final String logPrefix; - public HandlerResultAsyncListener(AtomicBoolean isCompleted, ServletServerHttpRequest request) { - this.isCompleted = isCompleted; - this.logPrefix = request.getLogPrefix(); + + public HttpHandlerAsyncListener( + AsyncListener requestAsyncListener, AsyncListener responseAsyncListener, + Runnable handlerDisposeTask, AtomicBoolean completionFlag, String logPrefix) { + + this.requestAsyncListener = requestAsyncListener; + this.responseAsyncListener = responseAsyncListener; + this.handlerDisposeTask = handlerDisposeTask; + this.completionFlag = completionFlag; + this.logPrefix = logPrefix; } + @Override public void onTimeout(AsyncEvent event) { - logger.debug(this.logPrefix + "Timeout notification"); - AsyncContext context = event.getAsyncContext(); - runIfAsyncNotComplete(context, this.isCompleted, context::complete); + // Should never happen since we call asyncContext.setTimeout(-1) + if (logger.isDebugEnabled()) { + logger.debug(this.logPrefix + "AsyncEvent onTimeout"); + } + delegateTimeout(this.requestAsyncListener, event); + delegateTimeout(this.responseAsyncListener, event); + handleTimeoutOrError(event); } @Override public void onError(AsyncEvent event) { Throwable ex = event.getThrowable(); - logger.debug(this.logPrefix + "Error notification: " + (ex != null ? ex : "")); - AsyncContext context = event.getAsyncContext(); - runIfAsyncNotComplete(context, this.isCompleted, context::complete); + if (logger.isDebugEnabled()) { + logger.debug(this.logPrefix + "AsyncEvent onError: " + (ex != null ? ex : "")); + } + delegateError(this.requestAsyncListener, event); + delegateError(this.responseAsyncListener, event); + handleTimeoutOrError(event); } @Override - public void onStartAsync(AsyncEvent event) { - // no-op + public void onComplete(AsyncEvent event) { + delegateComplete(this.requestAsyncListener, event); + delegateComplete(this.responseAsyncListener, event); + } + + private static void delegateTimeout(AsyncListener listener, AsyncEvent event) { + try { + listener.onTimeout(event); + } + catch (Exception ex) { + // Ignore + } + } + + private static void delegateError(AsyncListener listener, AsyncEvent event) { + try { + listener.onError(event); + } + catch (Exception ex) { + // Ignore + } + } + + private static void delegateComplete(AsyncListener listener, AsyncEvent event) { + try { + listener.onComplete(event); + } + catch (Exception ex) { + // Ignore + } + } + + private void handleTimeoutOrError(AsyncEvent event) { + AsyncContext context = event.getAsyncContext(); + runIfAsyncNotComplete(context, this.completionFlag, () -> { + try { + this.handlerDisposeTask.run(); + } + finally { + context.complete(); + } + }); } @Override - public void onComplete(AsyncEvent event) { + public void onStartAsync(AsyncEvent event) { // no-op } } - private static class HandlerResultSubscriber implements Subscriber { + private static class HandlerResultSubscriber implements Subscriber, Runnable { private final AsyncContext asyncContext; - private final AtomicBoolean isCompleted; + private final AtomicBoolean completionFlag; private final String logPrefix; + @Nullable + private volatile Subscription subscription; + public HandlerResultSubscriber( - AsyncContext asyncContext, AtomicBoolean isCompleted, ServletServerHttpRequest httpRequest) { + AsyncContext asyncContext, AtomicBoolean completionFlag, String logPrefix) { this.asyncContext = asyncContext; - this.isCompleted = isCompleted; - this.logPrefix = httpRequest.getLogPrefix(); + this.completionFlag = completionFlag; + this.logPrefix = logPrefix; } @Override public void onSubscribe(Subscription subscription) { + this.subscription = subscription; subscription.request(Long.MAX_VALUE); } @@ -314,8 +383,10 @@ public void onNext(Void aVoid) { @Override public void onError(Throwable ex) { - logger.trace(this.logPrefix + "Failed to complete: " + ex.getMessage()); - runIfAsyncNotComplete(this.asyncContext, this.isCompleted, () -> { + if (logger.isTraceEnabled()) { + logger.trace(this.logPrefix + "onError: " + ex); + } + runIfAsyncNotComplete(this.asyncContext, this.completionFlag, () -> { if (this.asyncContext.getResponse().isCommitted()) { logger.trace(this.logPrefix + "Dispatch to container, to raise the error on servlet thread"); this.asyncContext.getRequest().setAttribute(WRITE_ERROR_ATTRIBUTE_NAME, ex); @@ -336,8 +407,18 @@ public void onError(Throwable ex) { @Override public void onComplete() { - logger.trace(this.logPrefix + "Handling completed"); - runIfAsyncNotComplete(this.asyncContext, this.isCompleted, this.asyncContext::complete); + if (logger.isTraceEnabled()) { + logger.trace(this.logPrefix + "onComplete"); + } + runIfAsyncNotComplete(this.asyncContext, this.completionFlag, this.asyncContext::complete); + } + + @Override + public void run() { + Subscription s = this.subscription; + if (s != null) { + s.cancel(); + } } } diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/ServletServerHttpRequest.java b/spring-web/src/main/java/org/springframework/http/server/reactive/ServletServerHttpRequest.java index 693255b61b7f..a84ddc6d6e3d 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/ServletServerHttpRequest.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/ServletServerHttpRequest.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -73,6 +73,9 @@ class ServletServerHttpRequest extends AbstractServerHttpRequest { private final byte[] buffer; + private final AsyncListener asyncListener; + + public ServletServerHttpRequest(HttpServletRequest request, AsyncContext asyncContext, String servletPath, DataBufferFactory bufferFactory, int bufferSize) throws IOException, URISyntaxException { @@ -93,7 +96,7 @@ public ServletServerHttpRequest(MultiValueMap headers, HttpServl this.bufferFactory = bufferFactory; this.buffer = new byte[bufferSize]; - asyncContext.addListener(new RequestAsyncListener()); + this.asyncListener = new RequestAsyncListener(); // Tomcat expects ReadListener registration on initial thread ServletInputStream inputStream = request.getInputStream(); @@ -214,6 +217,22 @@ public Flux getBody() { return Flux.from(this.bodyPublisher); } + @SuppressWarnings("unchecked") + @Override + public T getNativeRequest() { + return (T) this.request; + } + + /** + * Return an {@link RequestAsyncListener} that completes the request body + * Publisher when the Servlet container notifies that request input has ended. + * The listener is not actually registered but is rather exposed for + * {@link ServletHttpHandlerAdapter} to ensure events are delegated. + */ + AsyncListener getAsyncListener() { + return this.asyncListener; + } + /** * Read from the request body InputStream and return a DataBuffer. * Invoked only when {@link ServletInputStream#isReady()} returns "true". @@ -245,12 +264,6 @@ protected final void logBytesRead(int read) { } } - @SuppressWarnings("unchecked") - @Override - public T getNativeRequest() { - return (T) this.request; - } - private final class RequestAsyncListener implements AsyncListener { diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/ServletServerHttpResponse.java b/spring-web/src/main/java/org/springframework/http/server/reactive/ServletServerHttpResponse.java index a4e17276498a..ab7dd93c8d39 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/ServletServerHttpResponse.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/ServletServerHttpResponse.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -65,6 +65,9 @@ class ServletServerHttpResponse extends AbstractListenerServerHttpResponse { private final ServletServerHttpRequest request; + private final ResponseAsyncListener asyncListener; + + public ServletServerHttpResponse(HttpServletResponse response, AsyncContext asyncContext, DataBufferFactory bufferFactory, int bufferSize, ServletServerHttpRequest request) throws IOException { @@ -85,7 +88,7 @@ public ServletServerHttpResponse(HttpHeaders headers, HttpServletResponse respon this.bufferSize = bufferSize; this.request = request; - asyncContext.addListener(new ResponseAsyncListener()); + this.asyncListener = new ResponseAsyncListener(); // Tomcat expects WriteListener registration on initial thread response.getOutputStream().setWriteListener(new ResponseBodyWriteListener()); @@ -165,6 +168,16 @@ protected void applyCookies() { } } + /** + * Return an {@link ResponseAsyncListener} that notifies the response + * body Publisher and Subscriber of Servlet container events. The listener + * is not actually registered but is rather exposed for + * {@link ServletHttpHandlerAdapter} to ensure events are delegated. + */ + AsyncListener getAsyncListener() { + return this.asyncListener; + } + @Override protected Processor, Void> createBodyFlushProcessor() { ResponseBodyFlushProcessor processor = new ResponseBodyFlushProcessor(); @@ -230,33 +243,37 @@ public void onError(AsyncEvent event) { handleError(event.getThrowable()); } - void handleError(Throwable ex) { + public void handleError(Throwable ex) { ResponseBodyFlushProcessor flushProcessor = bodyFlushProcessor; + ResponseBodyProcessor processor = bodyProcessor; if (flushProcessor != null) { + // Cancel the upstream source of "write" Publishers flushProcessor.cancel(); + // Cancel the current "write" Publisher and propagate onComplete downstream + if (processor != null) { + processor.cancel(); + processor.onError(ex); + } + // This is a no-op if processor was connected and onError propagated all the way flushProcessor.onError(ex); } - - ResponseBodyProcessor processor = bodyProcessor; - if (processor != null) { - processor.cancel(); - processor.onError(ex); - } } @Override public void onComplete(AsyncEvent event) { ResponseBodyFlushProcessor flushProcessor = bodyFlushProcessor; + ResponseBodyProcessor processor = bodyProcessor; if (flushProcessor != null) { + // Cancel the upstream source of "write" Publishers flushProcessor.cancel(); + // Cancel the current "write" Publisher and propagate onComplete downstream + if (processor != null) { + processor.cancel(); + processor.onComplete(); + } + // This is a no-op if processor was connected and onComplete propagated all the way flushProcessor.onComplete(); } - - ResponseBodyProcessor processor = bodyProcessor; - if (processor != null) { - processor.cancel(); - processor.onComplete(); - } } } @@ -264,7 +281,7 @@ public void onComplete(AsyncEvent event) { private class ResponseBodyWriteListener implements WriteListener { @Override - public void onWritePossible() throws IOException { + public void onWritePossible() { ResponseBodyProcessor processor = bodyProcessor; if (processor != null) { processor.onWritePossible(); @@ -279,18 +296,7 @@ public void onWritePossible() throws IOException { @Override public void onError(Throwable ex) { - ResponseBodyProcessor processor = bodyProcessor; - if (processor != null) { - processor.cancel(); - processor.onError(ex); - } - else { - ResponseBodyFlushProcessor flushProcessor = bodyFlushProcessor; - if (flushProcessor != null) { - flushProcessor.cancel(); - flushProcessor.onError(ex); - } - } + ServletServerHttpResponse.this.asyncListener.handleError(ex); } } @@ -311,7 +317,7 @@ protected Processor createWriteProcessor() { @Override protected void flush() throws IOException { if (rsWriteFlushLogger.isTraceEnabled()) { - rsWriteFlushLogger.trace(getLogPrefix() + "Flush attempt"); + rsWriteFlushLogger.trace(getLogPrefix() + "flushing"); } ServletServerHttpResponse.this.flush(); } @@ -349,7 +355,7 @@ protected boolean isDataEmpty(DataBuffer dataBuffer) { protected boolean write(DataBuffer dataBuffer) throws IOException { if (ServletServerHttpResponse.this.flushOnNext) { if (rsWriteLogger.isTraceEnabled()) { - rsWriteLogger.trace(getLogPrefix() + "Flush attempt"); + rsWriteLogger.trace(getLogPrefix() + "flushing"); } flush(); } diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/WriteResultPublisher.java b/spring-web/src/main/java/org/springframework/http/server/reactive/WriteResultPublisher.java index 39f6051b4f61..9bac8734bc56 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/WriteResultPublisher.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/WriteResultPublisher.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -50,6 +50,8 @@ class WriteResultPublisher implements Publisher { private final AtomicReference state = new AtomicReference<>(State.UNSUBSCRIBED); + private final Runnable cancelTask; + @Nullable private volatile Subscriber subscriber; @@ -61,7 +63,8 @@ class WriteResultPublisher implements Publisher { private final String logPrefix; - public WriteResultPublisher(String logPrefix) { + public WriteResultPublisher(String logPrefix, Runnable cancelTask) { + this.cancelTask = cancelTask; this.logPrefix = logPrefix; } @@ -69,7 +72,7 @@ public WriteResultPublisher(String logPrefix) { @Override public final void subscribe(Subscriber subscriber) { if (rsWriteResultLogger.isTraceEnabled()) { - rsWriteResultLogger.trace(this.logPrefix + this.state + " subscribe: " + subscriber); + rsWriteResultLogger.trace(this.logPrefix + "got subscriber " + subscriber); } this.state.get().subscribe(this, subscriber); } @@ -78,20 +81,22 @@ public final void subscribe(Subscriber subscriber) { * Invoke this to delegate a completion signal to the subscriber. */ public void publishComplete() { + State state = this.state.get(); if (rsWriteResultLogger.isTraceEnabled()) { - rsWriteResultLogger.trace(this.logPrefix + this.state + " publishComplete"); + rsWriteResultLogger.trace(this.logPrefix + "completed [" + state + "]"); } - this.state.get().publishComplete(this); + state.publishComplete(this); } /** * Invoke this to delegate an error signal to the subscriber. */ public void publishError(Throwable t) { + State state = this.state.get(); if (rsWriteResultLogger.isTraceEnabled()) { - rsWriteResultLogger.trace(this.logPrefix + this.state + " publishError: " + t); + rsWriteResultLogger.trace(this.logPrefix + "failed: " + t + " [" + state + "]"); } - this.state.get().publishError(this, t); + state.publishError(this, t); } private boolean changeState(State oldState, State newState) { @@ -114,20 +119,22 @@ public WriteResultSubscription(WriteResultPublisher publisher) { @Override public final void request(long n) { if (rsWriteResultLogger.isTraceEnabled()) { - rsWriteResultLogger.trace(this.publisher.logPrefix + state() + " request: " + n); + rsWriteResultLogger.trace(this.publisher.logPrefix + + "request " + (n != Long.MAX_VALUE ? n : "Long.MAX_VALUE")); } - state().request(this.publisher, n); + getState().request(this.publisher, n); } @Override public final void cancel() { + State state = getState(); if (rsWriteResultLogger.isTraceEnabled()) { - rsWriteResultLogger.trace(this.publisher.logPrefix + state() + " cancel"); + rsWriteResultLogger.trace(this.publisher.logPrefix + "cancel [" + state + "]"); } - state().cancel(this.publisher); + state.cancel(this.publisher); } - private State state() { + private State getState() { return this.publisher.state.get(); } } @@ -161,11 +168,11 @@ void subscribe(WriteResultPublisher publisher, Subscriber subscrib publisher.changeState(SUBSCRIBING, SUBSCRIBED); // Now safe to check "beforeSubscribed" flags, they won't change once in NO_DEMAND if (publisher.completedBeforeSubscribed) { - publisher.publishComplete(); + publisher.state.get().publishComplete(publisher); } - Throwable publisherError = publisher.errorBeforeSubscribed; - if (publisherError != null) { - publisher.publishError(publisherError); + Throwable ex = publisher.errorBeforeSubscribed; + if (ex != null) { + publisher.state.get().publishError(publisher, ex); } } else { @@ -244,7 +251,10 @@ void request(WriteResultPublisher publisher, long n) { } void cancel(WriteResultPublisher publisher) { - if (!publisher.changeState(this, COMPLETED)) { + if (publisher.changeState(this, COMPLETED)) { + publisher.cancelTask.run(); + } + else { publisher.state.get().cancel(publisher); } } diff --git a/spring-web/src/main/java/org/springframework/web/bind/MissingMatrixVariableException.java b/spring-web/src/main/java/org/springframework/web/bind/MissingMatrixVariableException.java index 3074a5c317a4..39d038aae4c4 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/MissingMatrixVariableException.java +++ b/spring-web/src/main/java/org/springframework/web/bind/MissingMatrixVariableException.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,7 +28,7 @@ * @see MissingPathVariableException */ @SuppressWarnings("serial") -public class MissingMatrixVariableException extends ServletRequestBindingException { +public class MissingMatrixVariableException extends MissingRequestValueException { private final String variableName; @@ -41,7 +41,20 @@ public class MissingMatrixVariableException extends ServletRequestBindingExcepti * @param parameter the method parameter */ public MissingMatrixVariableException(String variableName, MethodParameter parameter) { - super(""); + this(variableName, parameter, false); + } + + /** + * Constructor for use when a value was present but converted to {@code null}. + * @param variableName the name of the missing matrix variable + * @param parameter the method parameter + * @param missingAfterConversion whether the value became null after conversion + * @since 5.3.6 + */ + public MissingMatrixVariableException( + String variableName, MethodParameter parameter, boolean missingAfterConversion) { + + super("", missingAfterConversion); this.variableName = variableName; this.parameter = parameter; } @@ -49,8 +62,9 @@ public MissingMatrixVariableException(String variableName, MethodParameter param @Override public String getMessage() { - return "Missing matrix variable '" + this.variableName + - "' for method parameter of type " + this.parameter.getNestedParameterType().getSimpleName(); + return "Required matrix variable '" + this.variableName + "' for method parameter type " + + this.parameter.getNestedParameterType().getSimpleName() + " is " + + (isMissingAfterConversion() ? "present but converted to null" : "not present"); } /** diff --git a/spring-web/src/main/java/org/springframework/web/bind/MissingPathVariableException.java b/spring-web/src/main/java/org/springframework/web/bind/MissingPathVariableException.java index 191a27d4715a..1fd5e3f4ab30 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/MissingPathVariableException.java +++ b/spring-web/src/main/java/org/springframework/web/bind/MissingPathVariableException.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,7 +30,7 @@ * @see MissingMatrixVariableException */ @SuppressWarnings("serial") -public class MissingPathVariableException extends ServletRequestBindingException { +public class MissingPathVariableException extends MissingRequestValueException { private final String variableName; @@ -43,7 +43,20 @@ public class MissingPathVariableException extends ServletRequestBindingException * @param parameter the method parameter */ public MissingPathVariableException(String variableName, MethodParameter parameter) { - super(""); + this(variableName, parameter, false); + } + + /** + * Constructor for use when a value was present but converted to {@code null}. + * @param variableName the name of the missing path variable + * @param parameter the method parameter + * @param missingAfterConversion whether the value became null after conversion + * @since 5.3.6 + */ + public MissingPathVariableException( + String variableName, MethodParameter parameter, boolean missingAfterConversion) { + + super("", missingAfterConversion); this.variableName = variableName; this.parameter = parameter; } @@ -51,8 +64,9 @@ public MissingPathVariableException(String variableName, MethodParameter paramet @Override public String getMessage() { - return "Missing URI template variable '" + this.variableName + - "' for method parameter of type " + this.parameter.getNestedParameterType().getSimpleName(); + return "Required URI template variable '" + this.variableName + "' for method parameter type " + + this.parameter.getNestedParameterType().getSimpleName() + " is " + + (isMissingAfterConversion() ? "present but converted to null" : "not present"); } /** diff --git a/spring-web/src/main/java/org/springframework/web/bind/MissingRequestCookieException.java b/spring-web/src/main/java/org/springframework/web/bind/MissingRequestCookieException.java index 7a7ea209127a..fd113c2864a5 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/MissingRequestCookieException.java +++ b/spring-web/src/main/java/org/springframework/web/bind/MissingRequestCookieException.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,7 +28,7 @@ * @see MissingRequestHeaderException */ @SuppressWarnings("serial") -public class MissingRequestCookieException extends ServletRequestBindingException { +public class MissingRequestCookieException extends MissingRequestValueException { private final String cookieName; @@ -41,7 +41,20 @@ public class MissingRequestCookieException extends ServletRequestBindingExceptio * @param parameter the method parameter */ public MissingRequestCookieException(String cookieName, MethodParameter parameter) { - super(""); + this(cookieName, parameter, false); + } + + /** + * Constructor for use when a value was present but converted to {@code null}. + * @param cookieName the name of the missing request cookie + * @param parameter the method parameter + * @param missingAfterConversion whether the value became null after conversion + * @since 5.3.6 + */ + public MissingRequestCookieException( + String cookieName, MethodParameter parameter, boolean missingAfterConversion) { + + super("", missingAfterConversion); this.cookieName = cookieName; this.parameter = parameter; } @@ -49,8 +62,9 @@ public MissingRequestCookieException(String cookieName, MethodParameter paramete @Override public String getMessage() { - return "Missing cookie '" + this.cookieName + - "' for method parameter of type " + this.parameter.getNestedParameterType().getSimpleName(); + return "Required cookie '" + this.cookieName + "' for method parameter type " + + this.parameter.getNestedParameterType().getSimpleName() + " is " + + (isMissingAfterConversion() ? "present but converted to null" : "not present"); } /** diff --git a/spring-web/src/main/java/org/springframework/web/bind/MissingRequestHeaderException.java b/spring-web/src/main/java/org/springframework/web/bind/MissingRequestHeaderException.java index eb3bf660ea64..e60dfb169adb 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/MissingRequestHeaderException.java +++ b/spring-web/src/main/java/org/springframework/web/bind/MissingRequestHeaderException.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,7 +28,7 @@ * @see MissingRequestCookieException */ @SuppressWarnings("serial") -public class MissingRequestHeaderException extends ServletRequestBindingException { +public class MissingRequestHeaderException extends MissingRequestValueException { private final String headerName; @@ -41,7 +41,20 @@ public class MissingRequestHeaderException extends ServletRequestBindingExceptio * @param parameter the method parameter */ public MissingRequestHeaderException(String headerName, MethodParameter parameter) { - super(""); + this(headerName, parameter, false); + } + + /** + * Constructor for use when a value was present but converted to {@code null}. + * @param headerName the name of the missing request header + * @param parameter the method parameter + * @param missingAfterConversion whether the value became null after conversion + * @since 5.3.6 + */ + public MissingRequestHeaderException( + String headerName, MethodParameter parameter, boolean missingAfterConversion) { + + super("", missingAfterConversion); this.headerName = headerName; this.parameter = parameter; } @@ -49,8 +62,9 @@ public MissingRequestHeaderException(String headerName, MethodParameter paramete @Override public String getMessage() { - return "Missing request header '" + this.headerName + - "' for method parameter of type " + this.parameter.getNestedParameterType().getSimpleName(); + String typeName = this.parameter.getNestedParameterType().getSimpleName(); + return "Required request header '" + this.headerName + "' for method parameter type " + typeName + " is " + + (isMissingAfterConversion() ? "present but converted to null" : "not present"); } /** diff --git a/spring-web/src/main/java/org/springframework/web/bind/MissingRequestValueException.java b/spring-web/src/main/java/org/springframework/web/bind/MissingRequestValueException.java new file mode 100644 index 000000000000..1de083e3a8b5 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/bind/MissingRequestValueException.java @@ -0,0 +1,50 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.web.bind; + +/** + * Base class for {@link ServletRequestBindingException} exceptions that could + * not bind because the request value is required but is either missing or + * otherwise resolves to {@code null} after conversion. + * + * @author Rossen Stoyanchev + * @since 5.3.6 + */ +@SuppressWarnings("serial") +public class MissingRequestValueException extends ServletRequestBindingException { + + private final boolean missingAfterConversion; + + + public MissingRequestValueException(String msg) { + this(msg, false); + } + + public MissingRequestValueException(String msg, boolean missingAfterConversion) { + super(msg); + this.missingAfterConversion = missingAfterConversion; + } + + + /** + * Whether the request value was present but converted to {@code null}, e.g. via + * {@code org.springframework.core.convert.support.IdToEntityConverter}. + */ + public boolean isMissingAfterConversion() { + return this.missingAfterConversion; + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/bind/MissingServletRequestParameterException.java b/spring-web/src/main/java/org/springframework/web/bind/MissingServletRequestParameterException.java index 67feef6bfa84..8bab4da052e2 100644 --- a/spring-web/src/main/java/org/springframework/web/bind/MissingServletRequestParameterException.java +++ b/spring-web/src/main/java/org/springframework/web/bind/MissingServletRequestParameterException.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2012 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,7 +23,7 @@ * @since 2.0.2 */ @SuppressWarnings("serial") -public class MissingServletRequestParameterException extends ServletRequestBindingException { +public class MissingServletRequestParameterException extends MissingRequestValueException { private final String parameterName; @@ -36,7 +36,20 @@ public class MissingServletRequestParameterException extends ServletRequestBindi * @param parameterType the expected type of the missing parameter */ public MissingServletRequestParameterException(String parameterName, String parameterType) { - super(""); + this(parameterName, parameterType, false); + } + + /** + * Constructor for use when a value was present but converted to {@code null}. + * @param parameterName the name of the missing parameter + * @param parameterType the expected type of the missing parameter + * @param missingAfterConversion whether the value became null after conversion + * @since 5.3.6 + */ + public MissingServletRequestParameterException( + String parameterName, String parameterType, boolean missingAfterConversion) { + + super("", missingAfterConversion); this.parameterName = parameterName; this.parameterType = parameterType; } @@ -44,7 +57,9 @@ public MissingServletRequestParameterException(String parameterName, String para @Override public String getMessage() { - return "Required " + this.parameterType + " parameter '" + this.parameterName + "' is not present"; + return "Required request parameter '" + this.parameterName + "' for method parameter type " + + this.parameterType + " is " + + (isMissingAfterConversion() ? "present but converted to null" : "not present"); } /** diff --git a/spring-web/src/main/java/org/springframework/web/client/RestOperations.java b/spring-web/src/main/java/org/springframework/web/client/RestOperations.java index 03d3cfa3505d..e799385bd249 100644 --- a/spring-web/src/main/java/org/springframework/web/client/RestOperations.java +++ b/spring-web/src/main/java/org/springframework/web/client/RestOperations.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -499,7 +499,7 @@ T patchForObject(URI url, @Nullable Object request, Class responseType) * @param method the HTTP method (GET, POST, etc) * @param requestEntity the entity (headers and/or body) to write to the request * may be {@code null}) - * @param responseType the type of the return value + * @param responseType the type to convert the response to, or {@code Void.class} for no body * @param uriVariables the variables to expand in the template * @return the response as entity * @since 3.0.2 @@ -515,7 +515,7 @@ ResponseEntity exchange(String url, HttpMethod method, @Nullable HttpEnti * @param method the HTTP method (GET, POST, etc) * @param requestEntity the entity (headers and/or body) to write to the request * (may be {@code null}) - * @param responseType the type of the return value + * @param responseType the type to convert the response to, or {@code Void.class} for no body * @param uriVariables the variables to expand in the template * @return the response as entity * @since 3.0.2 @@ -530,7 +530,7 @@ ResponseEntity exchange(String url, HttpMethod method, @Nullable HttpEnti * @param method the HTTP method (GET, POST, etc) * @param requestEntity the entity (headers and/or body) to write to the request * (may be {@code null}) - * @param responseType the type of the return value + * @param responseType the type to convert the response to, or {@code Void.class} for no body * @return the response as entity * @since 3.0.2 */ @@ -552,7 +552,7 @@ ResponseEntity exchange(URI url, HttpMethod method, @Nullable HttpEntity< * @param method the HTTP method (GET, POST, etc) * @param requestEntity the entity (headers and/or body) to write to the * request (may be {@code null}) - * @param responseType the type of the return value + * @param responseType the type to convert the response to, or {@code Void.class} for no body * @param uriVariables the variables to expand in the template * @return the response as entity * @since 3.2 @@ -575,7 +575,7 @@ ResponseEntity exchange(String url,HttpMethod method, @Nullable HttpEntit * @param method the HTTP method (GET, POST, etc) * @param requestEntity the entity (headers and/or body) to write to the request * (may be {@code null}) - * @param responseType the type of the return value + * @param responseType the type to convert the response to, or {@code Void.class} for no body * @param uriVariables the variables to expand in the template * @return the response as entity * @since 3.2 @@ -598,7 +598,7 @@ ResponseEntity exchange(String url, HttpMethod method, @Nullable HttpEnti * @param method the HTTP method (GET, POST, etc) * @param requestEntity the entity (headers and/or body) to write to the request * (may be {@code null}) - * @param responseType the type of the return value + * @param responseType the type to convert the response to, or {@code Void.class} for no body * @return the response as entity * @since 3.2 */ @@ -618,7 +618,7 @@ ResponseEntity exchange(URI url, HttpMethod method, @Nullable HttpEntity< * ResponseEntity<MyResponse> response = template.exchange(request, MyResponse.class); * * @param requestEntity the entity to write to the request - * @param responseType the type of the return value + * @param responseType the type to convert the response to, or {@code Void.class} for no body * @return the response as entity * @since 4.1 */ @@ -640,7 +640,7 @@ ResponseEntity exchange(RequestEntity requestEntity, Class response * ResponseEntity<List<MyResponse>> response = template.exchange(request, myBean); * * @param requestEntity the entity to write to the request - * @param responseType the type of the return value + * @param responseType the type to convert the response to, or {@code Void.class} for no body * @return the response as entity * @since 4.1 */ diff --git a/spring-web/src/main/java/org/springframework/web/context/support/StandardServletEnvironment.java b/spring-web/src/main/java/org/springframework/web/context/support/StandardServletEnvironment.java index 00b09095e37b..3ec9b92be64a 100644 --- a/spring-web/src/main/java/org/springframework/web/context/support/StandardServletEnvironment.java +++ b/spring-web/src/main/java/org/springframework/web/context/support/StandardServletEnvironment.java @@ -54,9 +54,17 @@ public class StandardServletEnvironment extends StandardEnvironment implements C public static final String JNDI_PROPERTY_SOURCE_NAME = "jndiProperties"; + /** + * Create a new {@code StandardServletEnvironment} instance. + */ public StandardServletEnvironment() { } + /** + * Create a new {@code StandardServletEnvironment} instance with a specific {@link MutablePropertySources} instance. + * @param propertySources property sources to use + * @since 5.3.4 + */ protected StandardServletEnvironment(MutablePropertySources propertySources) { super(propertySources); } diff --git a/spring-web/src/main/java/org/springframework/web/filter/OncePerRequestFilter.java b/spring-web/src/main/java/org/springframework/web/filter/OncePerRequestFilter.java index 7aec81b07c29..0f5e1a3831ad 100644 --- a/spring-web/src/main/java/org/springframework/web/filter/OncePerRequestFilter.java +++ b/spring-web/src/main/java/org/springframework/web/filter/OncePerRequestFilter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -145,7 +145,7 @@ private boolean skipDispatch(HttpServletRequest request) { * @see WebAsyncManager#hasConcurrentResult() */ protected boolean isAsyncDispatch(HttpServletRequest request) { - return request.getDispatcherType().equals(DispatcherType.ASYNC); + return DispatcherType.ASYNC.equals(request.getDispatcherType()); } /** diff --git a/spring-web/src/main/java/org/springframework/web/method/HandlerTypePredicate.java b/spring-web/src/main/java/org/springframework/web/method/HandlerTypePredicate.java index b40e0483f3e1..2050bbdf7d6c 100644 --- a/spring-web/src/main/java/org/springframework/web/method/HandlerTypePredicate.java +++ b/spring-web/src/main/java/org/springframework/web/method/HandlerTypePredicate.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,6 +26,7 @@ import java.util.function.Predicate; import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.lang.Nullable; import org.springframework.util.ClassUtils; import org.springframework.util.StringUtils; @@ -69,7 +70,7 @@ private HandlerTypePredicate(Set basePackages, List> assignable @Override - public boolean test(Class controllerType) { + public boolean test(@Nullable Class controllerType) { if (!hasSelectors()) { return true; } diff --git a/spring-web/src/main/java/org/springframework/web/method/annotation/AbstractCookieValueMethodArgumentResolver.java b/spring-web/src/main/java/org/springframework/web/method/annotation/AbstractCookieValueMethodArgumentResolver.java index 99103719f53d..d1f28393aa74 100644 --- a/spring-web/src/main/java/org/springframework/web/method/annotation/AbstractCookieValueMethodArgumentResolver.java +++ b/spring-web/src/main/java/org/springframework/web/method/annotation/AbstractCookieValueMethodArgumentResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,6 +24,7 @@ import org.springframework.web.bind.ServletRequestBindingException; import org.springframework.web.bind.WebDataBinder; import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.context.request.NativeWebRequest; /** * A base abstract class to resolve method arguments annotated with @@ -70,6 +71,12 @@ protected void handleMissingValue(String name, MethodParameter parameter) throws throw new MissingRequestCookieException(name, parameter); } + @Override + protected void handleMissingValueAfterConversion( + String name, MethodParameter parameter, NativeWebRequest request) throws Exception { + + throw new MissingRequestCookieException(name, parameter, true); + } private static final class CookieValueNamedValueInfo extends NamedValueInfo { diff --git a/spring-web/src/main/java/org/springframework/web/method/annotation/AbstractNamedValueMethodArgumentResolver.java b/spring-web/src/main/java/org/springframework/web/method/annotation/AbstractNamedValueMethodArgumentResolver.java index c389b6f9c7e8..4e0c8095c608 100644 --- a/spring-web/src/main/java/org/springframework/web/method/annotation/AbstractNamedValueMethodArgumentResolver.java +++ b/spring-web/src/main/java/org/springframework/web/method/annotation/AbstractNamedValueMethodArgumentResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -135,7 +135,7 @@ else if ("".equals(arg) && namedValueInfo.defaultValue != null) { // Check for null value after conversion of incoming argument value if (arg == null && namedValueInfo.defaultValue == null && namedValueInfo.required && !nestedParameter.isOptional()) { - handleMissingValue(namedValueInfo.name, nestedParameter, webRequest); + handleMissingValueAfterConversion(namedValueInfo.name, nestedParameter, webRequest); } } @@ -237,6 +237,19 @@ protected void handleMissingValue(String name, MethodParameter parameter) throws "' for method parameter of type " + parameter.getNestedParameterType().getSimpleName()); } + /** + * Invoked when a named value is present but becomes {@code null} after conversion. + * @param name the name for the value + * @param parameter the method parameter + * @param request the current request + * @since 5.3.6 + */ + protected void handleMissingValueAfterConversion(String name, MethodParameter parameter, NativeWebRequest request) + throws Exception { + + handleMissingValue(name, parameter, request); + } + /** * A {@code null} results in a {@code false} value for {@code boolean}s or an exception for other primitives. */ diff --git a/spring-web/src/main/java/org/springframework/web/method/annotation/RequestHeaderMethodArgumentResolver.java b/spring-web/src/main/java/org/springframework/web/method/annotation/RequestHeaderMethodArgumentResolver.java index 85b856bf43c2..bfc79c787bba 100644 --- a/spring-web/src/main/java/org/springframework/web/method/annotation/RequestHeaderMethodArgumentResolver.java +++ b/spring-web/src/main/java/org/springframework/web/method/annotation/RequestHeaderMethodArgumentResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -87,6 +87,12 @@ protected void handleMissingValue(String name, MethodParameter parameter) throws throw new MissingRequestHeaderException(name, parameter); } + @Override + protected void handleMissingValueAfterConversion( + String name, MethodParameter parameter, NativeWebRequest request) throws Exception { + + throw new MissingRequestHeaderException(name, parameter, true); + } private static final class RequestHeaderNamedValueInfo extends NamedValueInfo { diff --git a/spring-web/src/main/java/org/springframework/web/method/annotation/RequestParamMethodArgumentResolver.java b/spring-web/src/main/java/org/springframework/web/method/annotation/RequestParamMethodArgumentResolver.java index 22e1bc3a5eb9..e1d373ca5b81 100644 --- a/spring-web/src/main/java/org/springframework/web/method/annotation/RequestParamMethodArgumentResolver.java +++ b/spring-web/src/main/java/org/springframework/web/method/annotation/RequestParamMethodArgumentResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -190,6 +190,20 @@ protected Object resolveName(String name, MethodParameter parameter, NativeWebRe protected void handleMissingValue(String name, MethodParameter parameter, NativeWebRequest request) throws Exception { + handleMissingValueInternal(name, parameter, request, false); + } + + @Override + protected void handleMissingValueAfterConversion( + String name, MethodParameter parameter, NativeWebRequest request) throws Exception { + + handleMissingValueInternal(name, parameter, request, true); + } + + protected void handleMissingValueInternal( + String name, MethodParameter parameter, NativeWebRequest request, boolean missingAfterConversion) + throws Exception { + HttpServletRequest servletRequest = request.getNativeRequest(HttpServletRequest.class); if (MultipartResolutionDelegate.isMultipartArgument(parameter)) { if (servletRequest == null || !MultipartResolutionDelegate.isMultipartRequest(servletRequest)) { @@ -201,7 +215,7 @@ protected void handleMissingValue(String name, MethodParameter parameter, Native } else { throw new MissingServletRequestParameterException(name, - parameter.getNestedParameterType().getSimpleName()); + parameter.getNestedParameterType().getSimpleName(), missingAfterConversion); } } diff --git a/spring-web/src/main/java/org/springframework/web/server/UnsupportedMediaTypeStatusException.java b/spring-web/src/main/java/org/springframework/web/server/UnsupportedMediaTypeStatusException.java index 1e1f7d9d05d9..67c2b63adc09 100644 --- a/spring-web/src/main/java/org/springframework/web/server/UnsupportedMediaTypeStatusException.java +++ b/spring-web/src/main/java/org/springframework/web/server/UnsupportedMediaTypeStatusException.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,9 +20,12 @@ import java.util.List; import org.springframework.core.ResolvableType; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.lang.Nullable; +import org.springframework.util.CollectionUtils; /** * Exception for errors that fit response status 415 (unsupported media type). @@ -41,6 +44,9 @@ public class UnsupportedMediaTypeStatusException extends ResponseStatusException @Nullable private final ResolvableType bodyType; + @Nullable + private final HttpMethod method; + /** * Constructor for when the specified Content-Type is invalid. @@ -50,13 +56,14 @@ public UnsupportedMediaTypeStatusException(@Nullable String reason) { this.contentType = null; this.supportedMediaTypes = Collections.emptyList(); this.bodyType = null; + this.method = null; } /** * Constructor for when the Content-Type can be parsed but is not supported. */ public UnsupportedMediaTypeStatusException(@Nullable MediaType contentType, List supportedTypes) { - this(contentType, supportedTypes, null); + this(contentType, supportedTypes, null, null); } /** @@ -65,11 +72,30 @@ public UnsupportedMediaTypeStatusException(@Nullable MediaType contentType, List */ public UnsupportedMediaTypeStatusException(@Nullable MediaType contentType, List supportedTypes, @Nullable ResolvableType bodyType) { + this(contentType, supportedTypes, bodyType, null); + } + + /** + * Constructor that provides the HTTP method. + * @since 5.3.6 + */ + public UnsupportedMediaTypeStatusException(@Nullable MediaType contentType, List supportedTypes, + @Nullable HttpMethod method) { + this(contentType, supportedTypes, null, method); + } + + /** + * Constructor for when trying to encode from or decode to a specific Java type. + * @since 5.3.6 + */ + public UnsupportedMediaTypeStatusException(@Nullable MediaType contentType, List supportedTypes, + @Nullable ResolvableType bodyType, @Nullable HttpMethod method) { super(HttpStatus.UNSUPPORTED_MEDIA_TYPE, initReason(contentType, bodyType)); this.contentType = contentType; this.supportedMediaTypes = Collections.unmodifiableList(supportedTypes); this.bodyType = bodyType; + this.method = method; } private static String initReason(@Nullable MediaType contentType, @Nullable ResolvableType bodyType) { @@ -107,4 +133,14 @@ public ResolvableType getBodyType() { return this.bodyType; } + @Override + public HttpHeaders getResponseHeaders() { + if (HttpMethod.PATCH != this.method || CollectionUtils.isEmpty(this.supportedMediaTypes) ) { + return HttpHeaders.EMPTY; + } + HttpHeaders headers = new HttpHeaders(); + headers.setAcceptPatch(this.supportedMediaTypes); + return headers; + } + } diff --git a/spring-web/src/main/java/org/springframework/web/server/adapter/WebHttpHandlerBuilder.java b/spring-web/src/main/java/org/springframework/web/server/adapter/WebHttpHandlerBuilder.java index d8ac37c97357..a73e682a9f73 100644 --- a/spring-web/src/main/java/org/springframework/web/server/adapter/WebHttpHandlerBuilder.java +++ b/spring-web/src/main/java/org/springframework/web/server/adapter/WebHttpHandlerBuilder.java @@ -23,6 +23,9 @@ import java.util.function.Function; import java.util.stream.Collectors; +import reactor.blockhound.BlockHound; +import reactor.blockhound.integration.BlockHoundIntegration; + import org.springframework.beans.factory.NoSuchBeanDefinitionException; import org.springframework.context.ApplicationContext; import org.springframework.core.annotation.AnnotationAwareOrderComparator; @@ -418,4 +421,20 @@ public WebHttpHandlerBuilder clone() { return new WebHttpHandlerBuilder(this); } + + /** + * {@code BlockHoundIntegration} for spring-web classes. + * @since 5.3.6 + */ + public static class SpringWebBlockHoundIntegration implements BlockHoundIntegration { + + @Override + public void applyTo(BlockHound.Builder builder) { + + // Avoid hard references potentially anywhere in spring-web (no need for structural dependency) + + builder.allowBlockingCallsInside("org.springframework.web.util.HtmlUtils", ""); + } + } + } diff --git a/spring-web/src/main/java/org/springframework/web/util/UriComponentsBuilder.java b/spring-web/src/main/java/org/springframework/web/util/UriComponentsBuilder.java index 1e0dc1d8d1cf..ebe9d5133e5c 100644 --- a/spring-web/src/main/java/org/springframework/web/util/UriComponentsBuilder.java +++ b/spring-web/src/main/java/org/springframework/web/util/UriComponentsBuilder.java @@ -357,18 +357,28 @@ public static InetSocketAddress parseForwardedFor( String value = matcher.group(1).trim(); String host = value; int portSeparatorIdx = value.lastIndexOf(':'); - if (portSeparatorIdx > value.lastIndexOf(']')) { + int squareBracketIdx = value.lastIndexOf(']'); + if (portSeparatorIdx > squareBracketIdx) { + if (squareBracketIdx == -1 && value.indexOf(':') != portSeparatorIdx) { + throw new IllegalArgumentException("Invalid IPv4 address: " + value); + } host = value.substring(0, portSeparatorIdx); - port = Integer.parseInt(value.substring(portSeparatorIdx + 1)); + try { + port = Integer.parseInt(value.substring(portSeparatorIdx + 1)); + } + catch (NumberFormatException ex) { + throw new IllegalArgumentException( + "Failed to parse a port from \"forwarded\"-type header value: " + value); + } } - return new InetSocketAddress(host, port); + return InetSocketAddress.createUnresolved(host, port); } } String forHeader = request.getHeaders().getFirst("X-Forwarded-For"); if (StringUtils.hasText(forHeader)) { String host = StringUtils.tokenizeToStringArray(forHeader, ",")[0]; - return new InetSocketAddress(host, port); + return InetSocketAddress.createUnresolved(host, port); } return null; @@ -886,7 +896,11 @@ private boolean isForwardedSslOn(HttpHeaders headers) { private void adaptForwardedHost(String rawValue) { int portSeparatorIdx = rawValue.lastIndexOf(':'); - if (portSeparatorIdx > rawValue.lastIndexOf(']')) { + int squareBracketIdx = rawValue.lastIndexOf(']'); + if (portSeparatorIdx > squareBracketIdx) { + if (squareBracketIdx == -1 && rawValue.indexOf(':') != portSeparatorIdx) { + throw new IllegalArgumentException("Invalid IPv4 address: " + rawValue); + } host(rawValue.substring(0, portSeparatorIdx)); port(Integer.parseInt(rawValue.substring(portSeparatorIdx + 1))); } diff --git a/spring-web/src/main/resources/META-INF/services/reactor.blockhound.integration.BlockHoundIntegration b/spring-web/src/main/resources/META-INF/services/reactor.blockhound.integration.BlockHoundIntegration new file mode 100644 index 000000000000..0676bf5e9420 --- /dev/null +++ b/spring-web/src/main/resources/META-INF/services/reactor.blockhound.integration.BlockHoundIntegration @@ -0,0 +1,15 @@ +# Copyright 2002-2021 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +org.springframework.web.server.adapter.WebHttpHandlerBuilder$SpringWebBlockHoundIntegration \ No newline at end of file diff --git a/spring-web/src/test/java/org/springframework/http/HttpEntityTests.java b/spring-web/src/test/java/org/springframework/http/HttpEntityTests.java index 7b97e86afd72..6ebf469d088e 100644 --- a/spring-web/src/test/java/org/springframework/http/HttpEntityTests.java +++ b/spring-web/src/test/java/org/springframework/http/HttpEntityTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -31,7 +31,7 @@ public class HttpEntityTests { @Test - public void noHeaders() { + void noHeaders() { String body = "foo"; HttpEntity entity = new HttpEntity<>(body); assertThat(entity.getBody()).isSameAs(body); @@ -39,7 +39,7 @@ public void noHeaders() { } @Test - public void httpHeaders() { + void httpHeaders() { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.TEXT_PLAIN); String body = "foo"; @@ -50,7 +50,7 @@ public void httpHeaders() { } @Test - public void multiValueMap() { + void multiValueMap() { MultiValueMap map = new LinkedMultiValueMap<>(); map.set("Content-Type", "text/plain"); String body = "foo"; @@ -61,30 +61,30 @@ public void multiValueMap() { } @Test - public void testEquals() { + void testEquals() { MultiValueMap map1 = new LinkedMultiValueMap<>(); map1.set("Content-Type", "text/plain"); MultiValueMap map2 = new LinkedMultiValueMap<>(); map2.set("Content-Type", "application/json"); - assertThat(new HttpEntity<>().equals(new HttpEntity())).isTrue(); - assertThat(new HttpEntity<>(map1).equals(new HttpEntity())).isFalse(); - assertThat(new HttpEntity<>().equals(new HttpEntity(map2))).isFalse(); + assertThat(new HttpEntity<>().equals(new HttpEntity<>())).isTrue(); + assertThat(new HttpEntity<>(map1).equals(new HttpEntity<>())).isFalse(); + assertThat(new HttpEntity<>().equals(new HttpEntity<>(map2))).isFalse(); - assertThat(new HttpEntity<>(map1).equals(new HttpEntity(map1))).isTrue(); - assertThat(new HttpEntity<>(map1).equals(new HttpEntity(map2))).isFalse(); + assertThat(new HttpEntity<>(map1).equals(new HttpEntity<>(map1))).isTrue(); + assertThat(new HttpEntity<>(map1).equals(new HttpEntity<>(map2))).isFalse(); assertThat(new HttpEntity(null, null).equals(new HttpEntity(null, null))).isTrue(); assertThat(new HttpEntity<>("foo", null).equals(new HttpEntity(null, null))).isFalse(); assertThat(new HttpEntity(null, null).equals(new HttpEntity<>("bar", null))).isFalse(); - assertThat(new HttpEntity<>("foo", map1).equals(new HttpEntity("foo", map1))).isTrue(); - assertThat(new HttpEntity<>("foo", map1).equals(new HttpEntity("bar", map1))).isFalse(); + assertThat(new HttpEntity<>("foo", map1).equals(new HttpEntity<>("foo", map1))).isTrue(); + assertThat(new HttpEntity<>("foo", map1).equals(new HttpEntity<>("bar", map1))).isFalse(); } @Test - public void responseEntity() { + void responseEntity() { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.TEXT_PLAIN); String body = "foo"; @@ -104,7 +104,7 @@ public void responseEntity() { } @Test - public void requestEntity() throws Exception { + void requestEntity() throws Exception { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.TEXT_PLAIN); String body = "foo"; diff --git a/spring-web/src/test/java/org/springframework/http/codec/ServerSentEventHttpMessageWriterTests.java b/spring-web/src/test/java/org/springframework/http/codec/ServerSentEventHttpMessageWriterTests.java index d9089b3c3763..aa2cd5835485 100644 --- a/spring-web/src/test/java/org/springframework/http/codec/ServerSentEventHttpMessageWriterTests.java +++ b/spring-web/src/test/java/org/springframework/http/codec/ServerSentEventHttpMessageWriterTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -150,8 +150,12 @@ void writePojo(String displayName, DataBufferFactory bufferFactory) { testWrite(source, outputMessage, Pojo.class); StepVerifier.create(outputMessage.getBody()) - .consumeNextWith(stringConsumer("data:{\"foo\":\"foofoo\",\"bar\":\"barbar\"}\n\n")) - .consumeNextWith(stringConsumer("data:{\"foo\":\"foofoofoo\",\"bar\":\"barbarbar\"}\n\n")) + .consumeNextWith(stringConsumer("data:")) + .consumeNextWith(stringConsumer("{\"foo\":\"foofoo\",\"bar\":\"barbar\"}")) + .consumeNextWith(stringConsumer("\n\n")) + .consumeNextWith(stringConsumer("data:")) + .consumeNextWith(stringConsumer("{\"foo\":\"foofoofoo\",\"bar\":\"barbarbar\"}")) + .consumeNextWith(stringConsumer("\n\n")) .expectComplete() .verify(); } @@ -168,12 +172,16 @@ void writePojoWithPrettyPrint(String displayName, DataBufferFactory bufferFactor testWrite(source, outputMessage, Pojo.class); StepVerifier.create(outputMessage.getBody()) - .consumeNextWith(stringConsumer("data:{\n" + + .consumeNextWith(stringConsumer("data:")) + .consumeNextWith(stringConsumer("{\n" + "data: \"foo\" : \"foofoo\",\n" + - "data: \"bar\" : \"barbar\"\n" + "data:}\n\n")) - .consumeNextWith(stringConsumer("data:{\n" + + "data: \"bar\" : \"barbar\"\n" + "data:}")) + .consumeNextWith(stringConsumer("\n\n")) + .consumeNextWith(stringConsumer("data:")) + .consumeNextWith(stringConsumer("{\n" + "data: \"foo\" : \"foofoofoo\",\n" + - "data: \"bar\" : \"barbarbar\"\n" + "data:}\n\n")) + "data: \"bar\" : \"barbarbar\"\n" + "data:}")) + .consumeNextWith(stringConsumer("\n\n")) .expectComplete() .verify(); } @@ -190,11 +198,9 @@ void writePojoWithCustomEncoding(String displayName, DataBufferFactory bufferFac assertThat(outputMessage.getHeaders().getContentType()).isEqualTo(mediaType); StepVerifier.create(outputMessage.getBody()) - .consumeNextWith(dataBuffer -> { - String value = dataBuffer.toString(charset); - DataBufferUtils.release(dataBuffer); - assertThat(value).isEqualTo("data:{\"foo\":\"foo\uD834\uDD1E\",\"bar\":\"bar\uD834\uDD1E\"}\n\n"); - }) + .consumeNextWith(stringConsumer("data:", charset)) + .consumeNextWith(stringConsumer("{\"foo\":\"foo\uD834\uDD1E\",\"bar\":\"bar\uD834\uDD1E\"}", charset)) + .consumeNextWith(stringConsumer("\n\n", charset)) .expectComplete() .verify(); } diff --git a/spring-web/src/test/java/org/springframework/http/codec/multipart/DefaultPartHttpMessageReaderTests.java b/spring-web/src/test/java/org/springframework/http/codec/multipart/DefaultPartHttpMessageReaderTests.java index 0226ea7e5577..8e812e720fb0 100644 --- a/spring-web/src/test/java/org/springframework/http/codec/multipart/DefaultPartHttpMessageReaderTests.java +++ b/spring-web/src/test/java/org/springframework/http/codec/multipart/DefaultPartHttpMessageReaderTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,6 +23,7 @@ import java.lang.annotation.Target; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Collections; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.stream.Stream; @@ -233,6 +234,43 @@ public void tooManyParts() throws InterruptedException { latch.await(); } + @ParameterizedDefaultPartHttpMessageReaderTest + public void quotedBoundary(String displayName, DefaultPartHttpMessageReader reader) throws InterruptedException { + MockServerHttpRequest request = createRequest( + new ClassPathResource("simple.multipart", getClass()), "\"simple-boundary\""); + + Flux result = reader.read(forClass(Part.class), request, emptyMap()); + + CountDownLatch latch = new CountDownLatch(2); + StepVerifier.create(result) + .consumeNextWith(part -> testPart(part, null, + "This is implicitly typed plain ASCII text.\r\nIt does NOT end with a linebreak.", latch)).as("Part 1") + .consumeNextWith(part -> testPart(part, null, + "This is explicitly typed plain ASCII text.\r\nIt DOES end with a linebreak.\r\n", latch)).as("Part 2") + .verifyComplete(); + + latch.await(); + } + + @ParameterizedDefaultPartHttpMessageReaderTest + public void utf8Headers(String displayName, DefaultPartHttpMessageReader reader) throws InterruptedException { + MockServerHttpRequest request = createRequest( + new ClassPathResource("utf8.multipart", getClass()), "\"simple-boundary\""); + + Flux result = reader.read(forClass(Part.class), request, emptyMap()); + + CountDownLatch latch = new CountDownLatch(1); + StepVerifier.create(result) + .consumeNextWith(part -> { + assertThat(part.headers()).containsEntry("Føø", Collections.singletonList("Bår")); + testPart(part, null, "This is plain ASCII text.", latch); + }) + .verifyComplete(); + + latch.await(); + } + + private void testBrowser(DefaultPartHttpMessageReader reader, Resource resource, String boundary) throws InterruptedException { diff --git a/spring-web/src/test/java/org/springframework/http/codec/xml/Jaxb2XmlEncoderTests.java b/spring-web/src/test/java/org/springframework/http/codec/xml/Jaxb2XmlEncoderTests.java index 8c22f80956a9..a32a3f727ebf 100644 --- a/spring-web/src/test/java/org/springframework/http/codec/xml/Jaxb2XmlEncoderTests.java +++ b/spring-web/src/test/java/org/springframework/http/codec/xml/Jaxb2XmlEncoderTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -37,6 +37,7 @@ import static java.nio.charset.StandardCharsets.UTF_8; import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.core.ResolvableType.forClass; import static org.springframework.core.io.buffer.DataBufferUtils.release; /** @@ -52,19 +53,13 @@ public Jaxb2XmlEncoderTests() { @Override @Test public void canEncode() { - assertThat(this.encoder.canEncode(ResolvableType.forClass(Pojo.class), - MediaType.APPLICATION_XML)).isTrue(); - assertThat(this.encoder.canEncode(ResolvableType.forClass(Pojo.class), - MediaType.TEXT_XML)).isTrue(); - assertThat(this.encoder.canEncode(ResolvableType.forClass(Pojo.class), - MediaType.APPLICATION_JSON)).isFalse(); - - assertThat(this.encoder.canEncode( - ResolvableType.forClass(Jaxb2XmlDecoderTests.TypePojo.class), - MediaType.APPLICATION_XML)).isTrue(); - - assertThat(this.encoder.canEncode(ResolvableType.forClass(getClass()), - MediaType.APPLICATION_XML)).isFalse(); + assertThat(this.encoder.canEncode(forClass(Pojo.class), MediaType.APPLICATION_XML)).isTrue(); + assertThat(this.encoder.canEncode(forClass(Pojo.class), MediaType.TEXT_XML)).isTrue(); + assertThat(this.encoder.canEncode(forClass(Pojo.class), new MediaType("application", "foo+xml"))).isTrue(); + assertThat(this.encoder.canEncode(forClass(Pojo.class), MediaType.APPLICATION_JSON)).isFalse(); + + assertThat(this.encoder.canEncode(forClass(Jaxb2XmlDecoderTests.TypePojo.class), MediaType.APPLICATION_XML)).isTrue(); + assertThat(this.encoder.canEncode(forClass(getClass()), MediaType.APPLICATION_XML)).isFalse(); // SPR-15464 assertThat(this.encoder.canEncode(ResolvableType.NONE, null)).isFalse(); diff --git a/spring-web/src/test/java/org/springframework/http/server/ServletServerHttpRequestTests.java b/spring-web/src/test/java/org/springframework/http/server/ServletServerHttpRequestTests.java index b0aa8e1cda06..336289185e55 100644 --- a/spring-web/src/test/java/org/springframework/http/server/ServletServerHttpRequestTests.java +++ b/spring-web/src/test/java/org/springframework/http/server/ServletServerHttpRequestTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -45,20 +45,20 @@ public class ServletServerHttpRequestTests { @BeforeEach - public void create() { + void create() { mockRequest = new MockHttpServletRequest(); request = new ServletServerHttpRequest(mockRequest); } @Test - public void getMethod() { + void getMethod() { mockRequest.setMethod("POST"); assertThat(request.getMethod()).as("Invalid method").isEqualTo(HttpMethod.POST); } @Test - public void getUriForSimplePath() throws URISyntaxException { + void getUriForSimplePath() throws URISyntaxException { URI uri = new URI("https://example.com/path"); mockRequest.setScheme(uri.getScheme()); mockRequest.setServerName(uri.getHost()); @@ -69,7 +69,7 @@ public void getUriForSimplePath() throws URISyntaxException { } @Test - public void getUriWithQueryString() throws URISyntaxException { + void getUriWithQueryString() throws URISyntaxException { URI uri = new URI("https://example.com/path?query"); mockRequest.setScheme(uri.getScheme()); mockRequest.setServerName(uri.getHost()); @@ -80,7 +80,7 @@ public void getUriWithQueryString() throws URISyntaxException { } @Test // SPR-16414 - public void getUriWithQueryParam() throws URISyntaxException { + void getUriWithQueryParam() throws URISyntaxException { mockRequest.setScheme("https"); mockRequest.setServerPort(443); mockRequest.setServerName("example.com"); @@ -90,7 +90,7 @@ public void getUriWithQueryParam() throws URISyntaxException { } @Test // SPR-16414 - public void getUriWithMalformedQueryParam() throws URISyntaxException { + void getUriWithMalformedQueryParam() throws URISyntaxException { mockRequest.setScheme("https"); mockRequest.setServerPort(443); mockRequest.setServerName("example.com"); @@ -100,7 +100,7 @@ public void getUriWithMalformedQueryParam() throws URISyntaxException { } @Test // SPR-13876 - public void getUriWithEncoding() throws URISyntaxException { + void getUriWithEncoding() throws URISyntaxException { URI uri = new URI("https://example.com/%E4%B8%AD%E6%96%87" + "?redirect=https%3A%2F%2Fgithub.com%2Fspring-projects%2Fspring-framework"); mockRequest.setScheme(uri.getScheme()); @@ -112,7 +112,7 @@ public void getUriWithEncoding() throws URISyntaxException { } @Test - public void getHeaders() { + void getHeaders() { String headerName = "MyHeader"; String headerValue1 = "value1"; String headerValue2 = "value2"; @@ -132,7 +132,7 @@ public void getHeaders() { } @Test - public void getHeadersWithEmptyContentTypeAndEncoding() { + void getHeadersWithEmptyContentTypeAndEncoding() { String headerName = "MyHeader"; String headerValue1 = "value1"; String headerValue2 = "value2"; @@ -152,8 +152,8 @@ public void getHeadersWithEmptyContentTypeAndEncoding() { } @Test - public void getBody() throws IOException { - byte[] content = "Hello World".getBytes("UTF-8"); + void getBody() throws IOException { + byte[] content = "Hello World".getBytes(StandardCharsets.UTF_8); mockRequest.setContent(content); byte[] result = FileCopyUtils.copyToByteArray(request.getBody()); @@ -161,16 +161,17 @@ public void getBody() throws IOException { } @Test - public void getFormBody() throws IOException { + void getFormBody() throws IOException { // Charset (SPR-8676) mockRequest.setContentType("application/x-www-form-urlencoded; charset=UTF-8"); mockRequest.setMethod("POST"); mockRequest.addParameter("name 1", "value 1"); - mockRequest.addParameter("name 2", new String[] {"value 2+1", "value 2+2"}); + mockRequest.addParameter("name 2", "value 2+1", "value 2+2"); mockRequest.addParameter("name 3", (String) null); byte[] result = FileCopyUtils.copyToByteArray(request.getBody()); - byte[] content = "name+1=value+1&name+2=value+2%2B1&name+2=value+2%2B2&name+3".getBytes("UTF-8"); + byte[] content = "name+1=value+1&name+2=value+2%2B1&name+2=value+2%2B2&name+3".getBytes( + StandardCharsets.UTF_8); assertThat(result).as("Invalid content returned").isEqualTo(content); } diff --git a/spring-web/src/test/java/org/springframework/http/server/reactive/ServerHttpRequestTests.java b/spring-web/src/test/java/org/springframework/http/server/reactive/ServerHttpRequestTests.java index d9ca717ca6a1..7eca5ed6e14e 100644 --- a/spring-web/src/test/java/org/springframework/http/server/reactive/ServerHttpRequestTests.java +++ b/spring-web/src/test/java/org/springframework/http/server/reactive/ServerHttpRequestTests.java @@ -30,6 +30,7 @@ import org.springframework.core.io.buffer.DefaultDataBufferFactory; import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; import org.springframework.util.MultiValueMap; import org.springframework.web.testfixture.servlet.DelegatingServletInputStream; import org.springframework.web.testfixture.servlet.MockAsyncContext; @@ -46,6 +47,7 @@ * * @author Rossen Stoyanchev * @author Sam Brannen + * @author Brian Clozel */ public class ServerHttpRequestTests { @@ -166,6 +168,18 @@ public void mutateHeaderBySettingHeaderValues() throws Exception { assertThat(request.getHeaders().get(headerName)).containsExactly(headerValue3); } + @Test // gh-26615 + void mutateContentTypeHeaderValue() throws Exception { + ServerHttpRequest request = createRequest("/path").mutate() + .headers(headers -> headers.setContentType(MediaType.APPLICATION_JSON)).build(); + + assertThat(request.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON); + + ServerHttpRequest mutated = request.mutate() + .headers(headers -> headers.setContentType(MediaType.APPLICATION_CBOR)).build(); + assertThat(mutated.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_CBOR); + } + @Test void mutateWithExistingContextPath() throws Exception { ServerHttpRequest request = createRequest("/context/path", "/context"); diff --git a/spring-web/src/test/java/org/springframework/web/filter/ForwardedHeaderFilterTests.java b/spring-web/src/test/java/org/springframework/web/filter/ForwardedHeaderFilterTests.java index cd1f6bbad896..f224591e4e0b 100644 --- a/spring-web/src/test/java/org/springframework/web/filter/ForwardedHeaderFilterTests.java +++ b/spring-web/src/test/java/org/springframework/web/filter/ForwardedHeaderFilterTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -36,6 +36,7 @@ import org.springframework.web.testfixture.servlet.MockHttpServletResponse; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.mockito.Mockito.mock; /** @@ -440,7 +441,7 @@ public void forwardedForIpV6Identifier() throws Exception { request.addHeader(FORWARDED, "for=\"[2001:db8:cafe::17]\""); HttpServletRequest actual = filterAndGetWrappedRequest(); - assertThat(actual.getRemoteAddr()).isEqualTo(actual.getRemoteHost()).isEqualTo("2001:db8:cafe:0:0:0:0:17"); + assertThat(actual.getRemoteAddr()).isEqualTo(actual.getRemoteHost()).isEqualTo("[2001:db8:cafe::17]"); assertThat(actual.getRemotePort()).isEqualTo(MockHttpServletRequest.DEFAULT_SERVER_PORT); } @@ -458,7 +459,7 @@ public void forwardedForIpV6IdentifierWithPort() throws Exception { request.addHeader(FORWARDED, "For=\"[2001:db8:cafe::17]:47011\""); HttpServletRequest actual = filterAndGetWrappedRequest(); - assertThat(actual.getRemoteAddr()).isEqualTo(actual.getRemoteHost()).isEqualTo("2001:db8:cafe:0:0:0:0:17"); + assertThat(actual.getRemoteAddr()).isEqualTo(actual.getRemoteHost()).isEqualTo("[2001:db8:cafe::17]"); assertThat(actual.getRemotePort()).isEqualTo(47011); } @@ -470,6 +471,13 @@ public void forwardedForMultipleIdentifiers() throws Exception { assertThat(actual.getRemoteAddr()).isEqualTo(actual.getRemoteHost()).isEqualTo("203.0.113.195"); assertThat(actual.getRemotePort()).isEqualTo(MockHttpServletRequest.DEFAULT_SERVER_PORT); } + + @Test // gh-26748 + public void forwardedForInvalidIpV6Address() { + request.addHeader(FORWARDED, "for=\"2a02:918:175:ab60:45ee:c12c:dac1:808b\""); + assertThatIllegalArgumentException().isThrownBy( + ForwardedHeaderFilterTests.this::filterAndGetWrappedRequest); + } } @Nested diff --git a/spring-web/src/test/java/org/springframework/web/server/session/HeaderWebSessionIdResolverTests.java b/spring-web/src/test/java/org/springframework/web/server/session/HeaderWebSessionIdResolverTests.java index 1347e2c05cbb..a787356f6ff1 100644 --- a/spring-web/src/test/java/org/springframework/web/server/session/HeaderWebSessionIdResolverTests.java +++ b/spring-web/src/test/java/org/springframework/web/server/session/HeaderWebSessionIdResolverTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,10 +15,6 @@ */ package org.springframework.web.server.session; -import java.util.Arrays; -import java.util.List; - -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.web.server.ServerWebExchange; @@ -34,106 +30,92 @@ * @author Greg Turnquist * @author Rob Winch */ -public class HeaderWebSessionIdResolverTests { - private HeaderWebSessionIdResolver idResolver; +class HeaderWebSessionIdResolverTests { - private ServerWebExchange exchange; + private final HeaderWebSessionIdResolver idResolver = new HeaderWebSessionIdResolver(); + + private ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/path")); - @BeforeEach - public void setUp() { - this.idResolver = new HeaderWebSessionIdResolver(); - this.exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/path")); - } @Test - public void expireWhenValidThenSetsEmptyHeader() { + void expireWhenValidThenSetsEmptyHeader() { this.idResolver.expireSession(this.exchange); - assertThat(this.exchange.getResponse().getHeaders().get(HeaderWebSessionIdResolver.DEFAULT_HEADER_NAME)).isEqualTo(Arrays.asList("")); + assertThat(this.exchange.getResponse().getHeaders().get(HeaderWebSessionIdResolver.DEFAULT_HEADER_NAME)).containsExactly(""); } @Test - public void expireWhenMultipleInvocationThenSetsSingleEmptyHeader() { + void expireWhenMultipleInvocationThenSetsSingleEmptyHeader() { this.idResolver.expireSession(this.exchange); - this.idResolver.expireSession(this.exchange); - assertThat(this.exchange.getResponse().getHeaders().get(HeaderWebSessionIdResolver.DEFAULT_HEADER_NAME)).isEqualTo(Arrays.asList("")); + assertThat(this.exchange.getResponse().getHeaders().get(HeaderWebSessionIdResolver.DEFAULT_HEADER_NAME)).containsExactly(""); } @Test - public void expireWhenAfterSetSessionIdThenSetsEmptyHeader() { + void expireWhenAfterSetSessionIdThenSetsEmptyHeader() { this.idResolver.setSessionId(this.exchange, "123"); - this.idResolver.expireSession(this.exchange); - assertThat(this.exchange.getResponse().getHeaders().get(HeaderWebSessionIdResolver.DEFAULT_HEADER_NAME)).isEqualTo(Arrays.asList("")); + assertThat(this.exchange.getResponse().getHeaders().get(HeaderWebSessionIdResolver.DEFAULT_HEADER_NAME)).containsExactly(""); } @Test - public void setSessionIdWhenValidThenSetsHeader() { + void setSessionIdWhenValidThenSetsHeader() { String id = "123"; - this.idResolver.setSessionId(this.exchange, id); - assertThat(this.exchange.getResponse().getHeaders().get(HeaderWebSessionIdResolver.DEFAULT_HEADER_NAME)).isEqualTo(Arrays.asList(id)); + assertThat(this.exchange.getResponse().getHeaders().get(HeaderWebSessionIdResolver.DEFAULT_HEADER_NAME)).containsExactly(id); } @Test - public void setSessionIdWhenMultipleThenSetsSingleHeader() { + void setSessionIdWhenMultipleThenSetsSingleHeader() { String id = "123"; this.idResolver.setSessionId(this.exchange, "overriddenByNextInvocation"); - this.idResolver.setSessionId(this.exchange, id); - assertThat(this.exchange.getResponse().getHeaders().get(HeaderWebSessionIdResolver.DEFAULT_HEADER_NAME)).isEqualTo(Arrays.asList(id)); + assertThat(this.exchange.getResponse().getHeaders().get(HeaderWebSessionIdResolver.DEFAULT_HEADER_NAME)).containsExactly(id); } @Test - public void setSessionIdWhenCustomHeaderNameThenSetsHeader() { + void setSessionIdWhenCustomHeaderNameThenSetsHeader() { String headerName = "x-auth"; String id = "123"; this.idResolver.setHeaderName(headerName); - this.idResolver.setSessionId(this.exchange, id); - assertThat(this.exchange.getResponse().getHeaders().get(headerName)).isEqualTo(Arrays.asList(id)); + assertThat(this.exchange.getResponse().getHeaders().get(headerName)).containsExactly(id); } @Test - public void setSessionIdWhenNullIdThenIllegalArgumentException() { + void setSessionIdWhenNullIdThenIllegalArgumentException() { assertThatIllegalArgumentException().isThrownBy(() -> - this.idResolver.setSessionId(this.exchange, (String) null)); + this.idResolver.setSessionId(this.exchange, null)); } @Test - public void resolveSessionIdsWhenNoIdsThenEmpty() { - List ids = this.idResolver.resolveSessionIds(this.exchange); - - assertThat(ids.isEmpty()).isTrue(); + void resolveSessionIdsWhenNoIdsThenEmpty() { + assertThat(this.idResolver.resolveSessionIds(this.exchange)).isEmpty(); } @Test - public void resolveSessionIdsWhenIdThenIdFound() { + void resolveSessionIdsWhenIdThenIdFound() { String id = "123"; this.exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/path") .header(HeaderWebSessionIdResolver.DEFAULT_HEADER_NAME, id)); - List ids = this.idResolver.resolveSessionIds(this.exchange); - - assertThat(ids).isEqualTo(Arrays.asList(id)); + assertThat(this.idResolver.resolveSessionIds(this.exchange)).containsExactly(id); } @Test - public void resolveSessionIdsWhenMultipleIdsThenIdsFound() { + void resolveSessionIdsWhenMultipleIdsThenIdsFound() { String id1 = "123"; String id2 = "abc"; this.exchange = MockServerWebExchange.from( MockServerHttpRequest.get("/path") .header(HeaderWebSessionIdResolver.DEFAULT_HEADER_NAME, id1, id2)); - List ids = this.idResolver.resolveSessionIds(this.exchange); - - assertThat(ids).isEqualTo(Arrays.asList(id1, id2)); + assertThat(this.idResolver.resolveSessionIds(this.exchange)).containsExactly(id1, id2); } + } diff --git a/spring-web/src/test/java/org/springframework/web/util/UriComponentsBuilderTests.java b/spring-web/src/test/java/org/springframework/web/util/UriComponentsBuilderTests.java index 13d9e61b7926..1db9b40628c5 100644 --- a/spring-web/src/test/java/org/springframework/web/util/UriComponentsBuilderTests.java +++ b/spring-web/src/test/java/org/springframework/web/util/UriComponentsBuilderTests.java @@ -453,6 +453,21 @@ void fromHttpRequestWithForwardedIPv6HostAndPort() { assertThat(result.toString()).isEqualTo("http://[1abc:2abc:3abc::5ABC:6abc]:8080/mvc-showcase"); } + @Test // gh-26748 + void fromHttpRequestWithForwardedInvalidIPv6Address() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setScheme("http"); + request.setServerName("localhost"); + request.setServerPort(-1); + request.setRequestURI("/mvc-showcase"); + request.addHeader("X-Forwarded-Host", "2a02:918:175:ab60:45ee:c12c:dac1:808b"); + + HttpRequest httpRequest = new ServletServerHttpRequest(request); + + assertThatIllegalArgumentException().isThrownBy(() -> + UriComponentsBuilder.fromHttpRequest(httpRequest).build()); + } + @Test void fromHttpRequestWithForwardedHost() { MockHttpServletRequest request = new MockHttpServletRequest(); diff --git a/spring-web/src/test/resources/org/springframework/http/codec/multipart/utf8.multipart b/spring-web/src/test/resources/org/springframework/http/codec/multipart/utf8.multipart new file mode 100644 index 000000000000..7867df68aea2 --- /dev/null +++ b/spring-web/src/test/resources/org/springframework/http/codec/multipart/utf8.multipart @@ -0,0 +1,5 @@ +--simple-boundary +Føø: Bår + +This is plain ASCII text. +--simple-boundary-- diff --git a/spring-web/src/testFixtures/java/org/springframework/web/testfixture/servlet/MockHttpServletResponse.java b/spring-web/src/testFixtures/java/org/springframework/web/testfixture/servlet/MockHttpServletResponse.java index 0459dcb7dbf6..f3215a88b253 100644 --- a/spring-web/src/testFixtures/java/org/springframework/web/testfixture/servlet/MockHttpServletResponse.java +++ b/spring-web/src/testFixtures/java/org/springframework/web/testfixture/servlet/MockHttpServletResponse.java @@ -378,10 +378,10 @@ private String getCookieHeader(Cookie cookie) { buf.append("; Domain=").append(cookie.getDomain()); } int maxAge = cookie.getMaxAge(); + ZonedDateTime expires = (cookie instanceof MockCookie ? ((MockCookie) cookie).getExpires() : null); if (maxAge >= 0) { buf.append("; Max-Age=").append(maxAge); buf.append("; Expires="); - ZonedDateTime expires = (cookie instanceof MockCookie ? ((MockCookie) cookie).getExpires() : null); if (expires != null) { buf.append(expires.format(DateTimeFormatter.RFC_1123_DATE_TIME)); } @@ -391,6 +391,10 @@ private String getCookieHeader(Cookie cookie) { buf.append(headers.getFirst(HttpHeaders.EXPIRES)); } } + else if (expires != null) { + buf.append("; Expires="); + buf.append(expires.format(DateTimeFormatter.RFC_1123_DATE_TIME)); + } if (cookie.getSecure()) { buf.append("; Secure"); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ExchangeFunction.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ExchangeFunction.java index 9078462f867d..79fe6f708cdd 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ExchangeFunction.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ExchangeFunction.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,12 +24,15 @@ * *

    For example: *

    - * ExchangeFunction exchangeFunction = ExchangeFunctions.create(new ReactorClientHttpConnector());
    - * ClientRequest<Void> request = ClientRequest.method(HttpMethod.GET, "https://example.com/resource").build();
    + * ExchangeFunction exchangeFunction =
    + *         ExchangeFunctions.create(new ReactorClientHttpConnector());
      *
    - * Mono<String> result = exchangeFunction
    + * URI url = URI.create("https://example.com/resource");
    + * ClientRequest request = ClientRequest.create(HttpMethod.GET, url).build();
    + *
    + * Mono<String> bodyMono = exchangeFunction
      *     .exchange(request)
    - *     .then(response -> response.bodyToMono(String.class));
    + *     .flatMap(response -> response.bodyToMono(String.class));
      * 
    * * @author Arjen Poutsma @@ -39,15 +42,15 @@ public interface ExchangeFunction { /** - * Exchange the given request for a response mono. + * Exchange the given request for a {@link ClientResponse} promise. * @param request the request to exchange * @return the delayed response */ Mono exchange(ClientRequest request); /** - * Filters this exchange function with the given {@code ExchangeFilterFunction}, resulting in a - * filtered {@code ExchangeFunction}. + * Filter the exchange function with the given {@code ExchangeFilterFunction}, + * resulting in a filtered {@code ExchangeFunction}. * @param filter the filter to apply to this exchange * @return the filtered exchange * @see ExchangeFilterFunction#apply(ExchangeFunction) diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ExchangeFunctions.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ExchangeFunctions.java index d06f6bda3d99..b6b4d58b6c96 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ExchangeFunctions.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/ExchangeFunctions.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -98,14 +98,14 @@ public Mono exchange(ClientRequest clientRequest) { Assert.notNull(clientRequest, "ClientRequest must not be null"); HttpMethod httpMethod = clientRequest.method(); URI url = clientRequest.url(); - String logPrefix = clientRequest.logPrefix(); return this.connector .connect(httpMethod, url, httpRequest -> clientRequest.writeTo(httpRequest, this.strategies)) .doOnRequest(n -> logRequest(clientRequest)) - .doOnCancel(() -> logger.debug(logPrefix + "Cancel signal (to close connection)")) + .doOnCancel(() -> logger.debug(clientRequest.logPrefix() + "Cancel signal (to close connection)")) .onErrorResume(WebClientUtils.WRAP_EXCEPTION_PREDICATE, t -> wrapException(t, clientRequest)) .map(httpResponse -> { + String logPrefix = getLogPrefix(clientRequest, httpResponse); logResponse(httpResponse, logPrefix); return new DefaultClientResponse( httpResponse, this.strategies, logPrefix, httpMethod.name() + " " + url, @@ -120,6 +120,10 @@ private void logRequest(ClientRequest request) { ); } + private String getLogPrefix(ClientRequest request, ClientHttpResponse response) { + return request.logPrefix() + "[" + response.getId() + "] "; + } + private void logResponse(ClientHttpResponse response, String logPrefix) { LogFormatUtils.traceDebug(logger, traceOn -> { int code = response.getRawStatusCode(); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/support/RouterFunctionMapping.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/support/RouterFunctionMapping.java index 5d25ee817e56..e9f78b295611 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/support/RouterFunctionMapping.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/server/support/RouterFunctionMapping.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -128,12 +128,15 @@ private List> routerFunctions() { } private void logRouterFunctions(List> routerFunctions) { - if (logger.isDebugEnabled()) { + if (mappingsLogger.isDebugEnabled()) { + routerFunctions.forEach(function -> mappingsLogger.debug("Mapped " + function)); + } + else if (logger.isDebugEnabled()) { int total = routerFunctions.size(); String message = total + " RouterFunction(s) in " + formatMappingName(); if (logger.isTraceEnabled()) { if (total > 0) { - routerFunctions.forEach(routerFunction -> logger.trace("Mapped " + routerFunction)); + routerFunctions.forEach(function -> logger.trace("Mapped " + function)); } else { logger.trace(message); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/handler/AbstractHandlerMapping.java b/spring-webflux/src/main/java/org/springframework/web/reactive/handler/AbstractHandlerMapping.java index 4fea378fe964..9176b731aaa9 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/handler/AbstractHandlerMapping.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/handler/AbstractHandlerMapping.java @@ -18,11 +18,13 @@ import java.util.Map; +import org.apache.commons.logging.Log; import reactor.core.publisher.Mono; import org.springframework.beans.factory.BeanNameAware; import org.springframework.context.support.ApplicationObjectSupport; import org.springframework.core.Ordered; +import org.springframework.core.log.LogDelegateFactory; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -51,6 +53,10 @@ public abstract class AbstractHandlerMapping extends ApplicationObjectSupport private static final WebHandler NO_OP_HANDLER = exchange -> Mono.empty(); + /** Dedicated "hidden" logger for request mappings. */ + protected final Log mappingsLogger = + LogDelegateFactory.getHiddenLog(HandlerMapping.class.getName() + ".Mappings"); + private final PathPatternParser patternParser; diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/handler/AbstractUrlHandlerMapping.java b/spring-webflux/src/main/java/org/springframework/web/reactive/handler/AbstractUrlHandlerMapping.java index 9a93a65a4d29..abaf4cf86f96 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/handler/AbstractUrlHandlerMapping.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/handler/AbstractUrlHandlerMapping.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,6 +21,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.function.BiPredicate; import reactor.core.publisher.Mono; @@ -57,6 +58,9 @@ public abstract class AbstractUrlHandlerMapping extends AbstractHandlerMapping { private final Map handlerMap = new LinkedHashMap<>(); + @Nullable + private BiPredicate handlerPredicate; + /** * Set whether to lazily initialize handlers. Only applicable to @@ -81,6 +85,23 @@ public final Map getHandlerMap() { return Collections.unmodifiableMap(this.handlerMap); } + /** + * Configure a predicate for extended matching of the handler that was + * matched by URL path. This allows for further narrowing of the mapping by + * checking additional properties of the request. If the predicate returns + * "false", it result in a no-match, which allows another + * {@link org.springframework.web.reactive.HandlerMapping} to match or + * result in a 404 (NOT_FOUND) response. + * @param handlerPredicate a bi-predicate to match the candidate handler + * against the current exchange. + * @since 5.3.5 + * @see org.springframework.web.reactive.socket.server.support.WebSocketUpgradeHandlerPredicate + */ + public void setHandlerPredicate(BiPredicate handlerPredicate) { + this.handlerPredicate = (this.handlerPredicate != null ? + this.handlerPredicate.and(handlerPredicate) : handlerPredicate); + } + @Override public Mono getHandlerInternal(ServerWebExchange exchange) { @@ -129,11 +150,7 @@ protected Object lookupHandler(PathContainer lookupPath, ServerWebExchange excha PathPattern.PathMatchInfo matchInfo = pattern.matchAndExtract(lookupPath); Assert.notNull(matchInfo, "Expected a match"); - return handleMatch(this.handlerMap.get(pattern), pattern, pathWithinMapping, matchInfo, exchange); - } - - private Object handleMatch(Object handler, PathPattern bestMatch, PathContainer pathWithinMapping, - PathPattern.PathMatchInfo matchInfo, ServerWebExchange exchange) { + Object handler = this.handlerMap.get(pattern); // Bean name or resolved handler? if (handler instanceof String) { @@ -141,10 +158,14 @@ private Object handleMatch(Object handler, PathPattern bestMatch, PathContainer handler = obtainApplicationContext().getBean(handlerName); } + if (this.handlerPredicate != null && !this.handlerPredicate.test(handler, exchange)) { + return null; + } + validateHandler(handler, exchange); exchange.getAttributes().put(BEST_MATCHING_HANDLER_ATTRIBUTE, handler); - exchange.getAttributes().put(BEST_MATCHING_PATTERN_ATTRIBUTE, bestMatch); + exchange.getAttributes().put(BEST_MATCHING_PATTERN_ATTRIBUTE, pattern); exchange.getAttributes().put(PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, pathWithinMapping); exchange.getAttributes().put(URI_TEMPLATE_VARIABLES_ATTRIBUTE, matchInfo.getUriVariables()); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/handler/SimpleUrlHandlerMapping.java b/spring-webflux/src/main/java/org/springframework/web/reactive/handler/SimpleUrlHandlerMapping.java index 430681f85b9a..966b316455bf 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/handler/SimpleUrlHandlerMapping.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/handler/SimpleUrlHandlerMapping.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -158,9 +158,16 @@ protected void registerHandlers(Map urlMap) throws BeansExceptio } registerHandler(url, handler); } - if (logger.isDebugEnabled()) { - logger.debug("Patterns " + getHandlerMap().keySet() + " in " + formatMappingName()); - } + logMappings(); + } + } + + private void logMappings() { + if (mappingsLogger.isDebugEnabled()) { + mappingsLogger.debug(formatMappingName() + " " + getHandlerMap()); + } + else if (logger.isDebugEnabled()) { + logger.debug("Patterns " + getHandlerMap().keySet() + " in " + formatMappingName()); } } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/PathResourceResolver.java b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/PathResourceResolver.java index beafe65d0d23..dd86b8e9a264 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/PathResourceResolver.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/PathResourceResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -110,7 +110,7 @@ private Mono getResource(String resourcePath, List */ protected Mono getResource(String resourcePath, Resource location) { try { - if (location instanceof ClassPathResource) { + if (!(location instanceof UrlResource)) { resourcePath = UriUtils.decode(resourcePath, StandardCharsets.UTF_8); } Resource resource = location.createRelative(resourcePath); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/ResourceUrlProvider.java b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/ResourceUrlProvider.java index 8bfcb0ccd7d7..b78d85320085 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/ResourceUrlProvider.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/ResourceUrlProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,12 +26,15 @@ import org.apache.commons.logging.LogFactory; import reactor.core.publisher.Mono; +import org.springframework.beans.BeansException; import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; import org.springframework.context.ApplicationListener; import org.springframework.context.event.ContextRefreshedEvent; import org.springframework.core.annotation.AnnotationAwareOrderComparator; import org.springframework.http.server.PathContainer; import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.lang.Nullable; import org.springframework.util.StringUtils; import org.springframework.web.reactive.handler.SimpleUrlHandlerMapping; import org.springframework.web.server.ServerWebExchange; @@ -47,15 +50,23 @@ * {@code ResourceHttpRequestHandler}s to make its decisions. * * @author Rossen Stoyanchev + * @author Brian Clozel * @since 5.0 */ -public class ResourceUrlProvider implements ApplicationListener { +public class ResourceUrlProvider implements ApplicationListener, ApplicationContextAware { private static final Log logger = LogFactory.getLog(ResourceUrlProvider.class); - private final Map handlerMap = new LinkedHashMap<>(); + @Nullable + private ApplicationContext applicationContext; + + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + this.applicationContext = applicationContext; + } /** * Return a read-only view of the resource handler mappings either manually @@ -83,8 +94,8 @@ public void registerHandlers(Map handlerMap) { @Override public void onApplicationEvent(ContextRefreshedEvent event) { - if (this.handlerMap.isEmpty()) { - detectResourceHandlers(event.getApplicationContext()); + if (this.applicationContext == event.getApplicationContext() && this.handlerMap.isEmpty()) { + detectResourceHandlers(this.applicationContext); } } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/ResourceWebHandler.java b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/ResourceWebHandler.java index 8b58e163fbeb..be4edf688ce2 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/ResourceWebHandler.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/ResourceWebHandler.java @@ -619,7 +619,9 @@ private Object formatLocations() { return this.locationValues.stream().collect(Collectors.joining("\", \"", "[\"", "\"]")); } else if (!this.locations.isEmpty()) { - return "[" + this.locations + "]"; + return "[" + this.locations.toString() + .replaceAll("class path resource", "Classpath") + .replaceAll("ServletContext resource", "ServletContext") + "]"; } return Collections.emptyList(); } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/AbstractHandlerMethodMapping.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/AbstractHandlerMethodMapping.java index e5a8e98ff1a3..d28af9d89fe1 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/AbstractHandlerMethodMapping.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/AbstractHandlerMethodMapping.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -211,6 +211,9 @@ protected void detectHandlerMethods(final Object handler) { if (logger.isTraceEnabled()) { logger.trace(formatMappings(userType, methods)); } + else if (mappingsLogger.isDebugEnabled()) { + mappingsLogger.debug(formatMappings(userType, methods)); + } methods.forEach((method, mapping) -> { Method invocableMethod = AopUtils.selectInvocableMethod(method, userType); registerHandlerMethod(handler, invocableMethod, mapping); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/RequestMappingInfoHandlerMapping.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/RequestMappingInfoHandlerMapping.java index 8bb0bd16b1dd..fe4478e646ee 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/RequestMappingInfoHandlerMapping.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/RequestMappingInfoHandlerMapping.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -37,6 +37,7 @@ import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.util.Assert; import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.method.HandlerMethod; import org.springframework.web.reactive.HandlerMapping; import org.springframework.web.reactive.result.condition.NameValueExpression; @@ -173,7 +174,8 @@ protected HandlerMethod handleNoMatch(Set infos, String httpMethod = request.getMethodValue(); Set methods = helper.getAllowedMethods(); if (HttpMethod.OPTIONS.matches(httpMethod)) { - HttpOptionsHandler handler = new HttpOptionsHandler(methods); + Set mediaTypes = helper.getConsumablePatchMediaTypes(); + HttpOptionsHandler handler = new HttpOptionsHandler(methods, mediaTypes); return new HandlerMethod(handler, HTTP_OPTIONS_HANDLE_METHOD); } throw new MethodNotAllowedException(httpMethod, methods); @@ -188,7 +190,7 @@ protected HandlerMethod handleNoMatch(Set infos, catch (InvalidMediaTypeException ex) { throw new UnsupportedMediaTypeStatusException(ex.getMessage()); } - throw new UnsupportedMediaTypeStatusException(contentType, new ArrayList<>(mediaTypes)); + throw new UnsupportedMediaTypeStatusException(contentType, new ArrayList<>(mediaTypes), exchange.getRequest().getMethod()); } if (helper.hasProducesMismatch()) { @@ -301,6 +303,22 @@ public List>> getParamConditions() { collect(Collectors.toList()); } + /** + * Return declared "consumable" types but only among those that have + * PATCH specified, or that have no methods at all. + */ + public Set getConsumablePatchMediaTypes() { + Set result = new LinkedHashSet<>(); + for (PartialMatch match : this.partialMatches) { + Set methods = match.getInfo().getMethodsCondition().getMethods(); + if (methods.isEmpty() || methods.contains(RequestMethod.PATCH)) { + result.addAll(match.getInfo().getConsumesCondition().getConsumableMediaTypes()); + } + } + return result; + } + + /** * Container for a RequestMappingInfo that matches the URL path at least. @@ -367,8 +385,9 @@ private static class HttpOptionsHandler { private final HttpHeaders headers = new HttpHeaders(); - public HttpOptionsHandler(Set declaredMethods) { + public HttpOptionsHandler(Set declaredMethods, Set acceptPatch) { this.headers.setAllow(initAllowedHttpMethods(declaredMethods)); + this.headers.setAcceptPatch(new ArrayList<>(acceptPatch)); } private static Set initAllowedHttpMethods(Set declaredMethods) { diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractMessageWriterResultHandler.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractMessageWriterResultHandler.java index 08837c3fa228..cbdd2f851fce 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractMessageWriterResultHandler.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractMessageWriterResultHandler.java @@ -29,6 +29,7 @@ import org.springframework.core.ReactiveAdapterRegistry; import org.springframework.core.ResolvableType; import org.springframework.core.codec.Hints; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.codec.HttpMessageWriter; import org.springframework.http.converter.HttpMessageNotWritableException; @@ -144,7 +145,20 @@ protected Mono writeBody(@Nullable Object body, MethodParameter bodyParame return Mono.from((Publisher) publisher); } - MediaType bestMediaType = selectMediaType(exchange, () -> getMediaTypesFor(elementType)); + MediaType bestMediaType; + try { + bestMediaType = selectMediaType(exchange, () -> getMediaTypesFor(elementType)); + } + catch (NotAcceptableStatusException ex) { + HttpStatus statusCode = exchange.getResponse().getStatusCode(); + if (statusCode != null && statusCode.isError()) { + if (logger.isDebugEnabled()) { + logger.debug("Ignoring error response content (if any). " + ex.getReason()); + } + return Mono.empty(); + } + throw ex; + } if (bestMediaType != null) { String logPrefix = exchange.getLogPrefix(); if (logger.isDebugEnabled()) { diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMapping.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMapping.java index c9f6e951914e..e58948a04863 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMapping.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMapping.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -341,7 +341,7 @@ else if (!allowCredentials.isEmpty()) { "or an empty string (\"\"): current value is [" + allowCredentials + "]"); } - if (annotation.maxAge() >= 0 && config.getMaxAge() == null) { + if (annotation.maxAge() >= 0) { config.setMaxAge(annotation.maxAge()); } } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandler.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandler.java index 231bdca82c5b..430e7bd52c9d 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandler.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -320,7 +320,20 @@ private Mono render(List views, Map model, } } List mediaTypes = getMediaTypes(views); - MediaType bestMediaType = selectMediaType(exchange, () -> mediaTypes); + MediaType bestMediaType; + try { + bestMediaType = selectMediaType(exchange, () -> mediaTypes); + } + catch (NotAcceptableStatusException ex) { + HttpStatus statusCode = exchange.getResponse().getStatusCode(); + if (statusCode != null && statusCode.isError()) { + if (logger.isDebugEnabled()) { + logger.debug("Ignoring error response content (if any). " + ex.getReason()); + } + return Mono.empty(); + } + throw ex; + } if (bestMediaType != null) { for (View view : views) { for (MediaType mediaType : view.getSupportedMediaTypes()) { diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/socket/HandshakeInfo.java b/spring-webflux/src/main/java/org/springframework/web/reactive/socket/HandshakeInfo.java index 05a1110a23f9..657a20408c84 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/socket/HandshakeInfo.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/socket/HandshakeInfo.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,9 +24,12 @@ import reactor.core.publisher.Mono; +import org.springframework.http.HttpCookie; import org.springframework.http.HttpHeaders; import org.springframework.lang.Nullable; import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.MultiValueMap; /** * Simple container of information related to the handshake request that started @@ -38,12 +41,18 @@ */ public class HandshakeInfo { + private static final MultiValueMap EMPTY_COOKIES = + CollectionUtils.toMultiValueMap(Collections.emptyMap()); + + private final URI uri; private final Mono principalMono; private final HttpHeaders headers; + private final MultiValueMap cookies; + @Nullable private final String protocol; @@ -64,34 +73,57 @@ public class HandshakeInfo { * @param protocol the negotiated sub-protocol (may be {@code null}) */ public HandshakeInfo(URI uri, HttpHeaders headers, Mono principal, @Nullable String protocol) { - this(uri, headers, principal, protocol, null, Collections.emptyMap(), null); + this(uri, headers, EMPTY_COOKIES, principal, protocol, null, Collections.emptyMap(), null); } /** - * Constructor targetting server-side use with extra information about the - * handshake, the remote address, and a pre-existing log prefix for - * correlation. + * Constructor targeting server-side use with extra information such as the + * the remote address, attributes, and a log prefix. * @param uri the endpoint URL - * @param headers request headers for server or response headers or client + * @param headers server request headers * @param principal the principal for the session * @param protocol the negotiated sub-protocol (may be {@code null}) - * @param remoteAddress the remote address where the handshake came from - * @param attributes initial attributes to use for the WebSocket session - * @param logPrefix log prefix used during the handshake for correlating log - * messages, if any. + * @param remoteAddress the remote address of the client + * @param attributes initial attributes for the WebSocket session + * @param logPrefix the log prefix for the handshake request. * @since 5.1 + * @deprecated as of 5.3.5 in favor of + * {@link #HandshakeInfo(URI, HttpHeaders, MultiValueMap, Mono, String, InetSocketAddress, Map, String)} */ + @Deprecated public HandshakeInfo(URI uri, HttpHeaders headers, Mono principal, - @Nullable String protocol, @Nullable InetSocketAddress remoteAddress, - Map attributes, @Nullable String logPrefix) { + @Nullable String protocol, @Nullable InetSocketAddress remoteAddress, + Map attributes, @Nullable String logPrefix) { + + this(uri, headers, EMPTY_COOKIES, principal, protocol, remoteAddress, attributes, logPrefix); + } + + /** + * Constructor targeting server-side use with extra information such as the + * cookies, remote address, attributes, and a log prefix. + * @param uri the endpoint URL + * @param headers server request headers + * @param cookies server request cookies + * @param principal the principal for the session + * @param protocol the negotiated sub-protocol (may be {@code null}) + * @param remoteAddress the remote address of the client + * @param attributes initial attributes for the WebSocket session + * @param logPrefix the log prefix for the handshake request. + * @since 5.3.5 + */ + public HandshakeInfo(URI uri, HttpHeaders headers, MultiValueMap cookies, + Mono principal, @Nullable String protocol, @Nullable InetSocketAddress remoteAddress, + Map attributes, @Nullable String logPrefix) { Assert.notNull(uri, "URI is required"); Assert.notNull(headers, "HttpHeaders are required"); + Assert.notNull(cookies, "`cookies` are required"); Assert.notNull(principal, "Principal is required"); Assert.notNull(attributes, "'attributes' is required"); this.uri = uri; this.headers = headers; + this.cookies = cookies; this.principalMono = principal; this.protocol = protocol; this.remoteAddress = remoteAddress; @@ -108,15 +140,25 @@ public URI getUri() { } /** - * Return the handshake HTTP headers. Those are the request headers for a - * server session and the response headers for a client session. + * Return the HTTP headers from the handshake request, either server request + * headers for a server session or the client response headers for a client + * session. */ public HttpHeaders getHeaders() { return this.headers; } /** - * Return the principal associated with the handshake HTTP request. + * For a server session this returns the server request cookies from the + * handshake request. For a client session, it is an empty map. + * @since 5.3.5 + */ + public MultiValueMap getCookies() { + return this.cookies; + } + + /** + * Return the principal associated with the handshake request, if any. */ public Mono getPrincipal() { return this.principalMono; @@ -133,8 +175,8 @@ public String getSubProtocol() { } /** - * For a server-side session this is the remote address where the handshake - * request came from. + * For a server session this is the remote address where the handshake + * request came from. For a client session, it is {@code null}. * @since 5.1 */ @Nullable @@ -143,8 +185,7 @@ public InetSocketAddress getRemoteAddress() { } /** - * Attributes extracted from the handshake request to be added to the - * WebSocket session. + * Attributes extracted from the handshake request to add to the session. * @since 5.1 */ public Map getAttributes() { @@ -164,7 +205,7 @@ public String getLogPrefix() { @Override public String toString() { - return "HandshakeInfo[uri=" + this.uri + ", headers=" + this.headers + "]"; + return "HandshakeInfo[uri=" + this.uri + "]"; } } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/socket/server/support/HandshakeWebSocketService.java b/spring-webflux/src/main/java/org/springframework/web/reactive/socket/server/support/HandshakeWebSocketService.java index 9854e7578d10..9255dd6ea7f5 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/socket/server/support/HandshakeWebSocketService.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/socket/server/support/HandshakeWebSocketService.java @@ -30,12 +30,14 @@ import reactor.core.publisher.Mono; import org.springframework.context.Lifecycle; +import org.springframework.http.HttpCookie; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; +import org.springframework.util.MultiValueMap; import org.springframework.util.ReflectionUtils; import org.springframework.util.StringUtils; import org.springframework.web.reactive.socket.HandshakeInfo; @@ -282,10 +284,11 @@ private HandshakeInfo createHandshakeInfo(ServerWebExchange exchange, ServerHttp // the server implementation once the handshake HTTP exchange is done. HttpHeaders headers = new HttpHeaders(); headers.addAll(request.getHeaders()); + MultiValueMap cookies = request.getCookies(); Mono principal = exchange.getPrincipal(); String logPrefix = exchange.getLogPrefix(); InetSocketAddress remoteAddress = request.getRemoteAddress(); - return new HandshakeInfo(uri, headers, principal, protocol, remoteAddress, attributes, logPrefix); + return new HandshakeInfo(uri, headers, cookies, principal, protocol, remoteAddress, attributes, logPrefix); } } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/socket/server/support/WebSocketUpgradeHandlerPredicate.java b/spring-webflux/src/main/java/org/springframework/web/reactive/socket/server/support/WebSocketUpgradeHandlerPredicate.java new file mode 100644 index 000000000000..0ee33929ab14 --- /dev/null +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/socket/server/support/WebSocketUpgradeHandlerPredicate.java @@ -0,0 +1,45 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.socket.server.support; + +import java.util.function.BiPredicate; + +import org.springframework.web.reactive.socket.WebSocketHandler; +import org.springframework.web.server.ServerWebExchange; + +/** + * A predicate for use with + * {@link org.springframework.web.reactive.handler.AbstractUrlHandlerMapping#setHandlerPredicate} + * to ensure only WebSocket handshake requests are matched to handlers of type + * {@link WebSocketHandler}. + * + * @author Rossen Stoyanchev + * @since 5.3.5 + */ +public class WebSocketUpgradeHandlerPredicate implements BiPredicate { + + @Override + public boolean test(Object handler, ServerWebExchange exchange) { + if (handler instanceof WebSocketHandler) { + String method = exchange.getRequest().getMethodValue(); + String header = exchange.getRequest().getHeaders().getUpgrade(); + return (method.equals("GET") && header != null && header.equalsIgnoreCase("websocket")); + } + return true; + } + +} diff --git a/spring-webflux/src/main/kotlin/org/springframework/web/reactive/function/client/WebClientExtensions.kt b/spring-webflux/src/main/kotlin/org/springframework/web/reactive/function/client/WebClientExtensions.kt index d48b0b020761..0d18bc259646 100644 --- a/spring-webflux/src/main/kotlin/org/springframework/web/reactive/function/client/WebClientExtensions.kt +++ b/spring-webflux/src/main/kotlin/org/springframework/web/reactive/function/client/WebClientExtensions.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.reactive.asFlow import kotlinx.coroutines.reactive.awaitSingle import kotlinx.coroutines.reactive.awaitSingle +import kotlinx.coroutines.reactive.awaitSingleOrNull import kotlinx.coroutines.reactor.asFlux import kotlinx.coroutines.reactor.mono import org.reactivestreams.Publisher @@ -143,6 +144,18 @@ suspend inline fun WebClient.ResponseSpec.awaitBody() : T = else -> bodyToMono().awaitSingle() } +/** + * Coroutines variant of [WebClient.ResponseSpec.bodyToMono]. + * + * @author Valentin Shakhov + * @since 5.3.6 + */ +suspend inline fun WebClient.ResponseSpec.awaitBodyOrNull() : T? = + when (T::class) { + Unit::class -> awaitBodilessEntity().let { Unit as T? } + else -> bodyToMono().awaitSingleOrNull() + } + /** * Coroutines variant of [WebClient.ResponseSpec.toBodilessEntity]. */ diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/resource/ResourceUrlProviderTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/resource/ResourceUrlProviderTests.java index 45dbdcc1b0ec..c4ffa23a1c1c 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/resource/ResourceUrlProviderTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/resource/ResourceUrlProviderTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -44,6 +44,7 @@ * Unit tests for {@link ResourceUrlProvider}. * * @author Rossen Stoyanchev + * @author Brian Clozel */ public class ResourceUrlProviderTests { @@ -62,7 +63,7 @@ public class ResourceUrlProviderTests { @BeforeEach - public void setup() throws Exception { + void setup() throws Exception { this.locations.add(new ClassPathResource("test/", getClass())); this.locations.add(new ClassPathResource("testalternatepath/", getClass())); this.handler.setLocations(this.locations); @@ -73,7 +74,7 @@ public void setup() throws Exception { @Test - public void getStaticResourceUrl() { + void getStaticResourceUrl() { String expected = "/resources/foo.css"; String actual = this.urlProvider.getForUriString(expected, this.exchange).block(TIMEOUT); @@ -81,7 +82,7 @@ public void getStaticResourceUrl() { } @Test // SPR-13374 - public void getStaticResourceUrlRequestWithQueryOrHash() { + void getStaticResourceUrlRequestWithQueryOrHash() { String url = "/resources/foo.css?foo=bar&url=https://example.org"; String resolvedUrl = this.urlProvider.getForUriString(url, this.exchange).block(TIMEOUT); @@ -93,7 +94,7 @@ public void getStaticResourceUrlRequestWithQueryOrHash() { } @Test - public void getVersionedResourceUrl() { + void getVersionedResourceUrl() { VersionResourceResolver versionResolver = new VersionResourceResolver(); versionResolver.setStrategyMap(Collections.singletonMap("/**", new ContentVersionStrategy())); List resolvers = new ArrayList<>(); @@ -108,7 +109,7 @@ public void getVersionedResourceUrl() { } @Test // SPR-12647 - public void bestPatternMatch() { + void bestPatternMatch() { ResourceWebHandler otherHandler = new ResourceWebHandler(); otherHandler.setLocations(this.locations); @@ -129,7 +130,7 @@ public void bestPatternMatch() { @Test // SPR-12592 @SuppressWarnings("resource") - public void initializeOnce() { + void initializeOnce() { AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext(); context.setServletContext(new MockServletContext()); context.register(HandlerMappingConfiguration.class); @@ -139,6 +140,26 @@ public void initializeOnce() { .hasKeySatisfying(pathPatternStringOf("/resources/**")); } + @Test + void initializeOnCurrentContext() { + AnnotationConfigWebApplicationContext parentContext = new AnnotationConfigWebApplicationContext(); + parentContext.setServletContext(new MockServletContext()); + parentContext.register(ParentHandlerMappingConfiguration.class); + + AnnotationConfigWebApplicationContext childContext = new AnnotationConfigWebApplicationContext(); + childContext.setParent(parentContext); + childContext.setServletContext(new MockServletContext()); + childContext.register(HandlerMappingConfiguration.class); + + parentContext.refresh(); + childContext.refresh(); + + ResourceUrlProvider parentUrlProvider = parentContext.getBean(ResourceUrlProvider.class); + assertThat(parentUrlProvider.getHandlerMap()).isEmpty(); + ResourceUrlProvider childUrlProvider = childContext.getBean(ResourceUrlProvider.class); + assertThat(childUrlProvider.getHandlerMap()).hasKeySatisfying(pathPatternStringOf("/resources/**")); + } + private Condition pathPatternStringOf(String expected) { return new Condition( @@ -161,4 +182,14 @@ public ResourceUrlProvider resourceUrlProvider() { } } + @Configuration + @SuppressWarnings({"unused", "WeakerAccess"}) + static class ParentHandlerMappingConfiguration { + + @Bean + public ResourceUrlProvider resourceUrlProvider() { + return new ResourceUrlProvider(); + } + } + } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/resource/ResourceWebHandlerTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/resource/ResourceWebHandlerTests.java index 1232a9a60dfd..691afde68ee3 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/resource/ResourceWebHandlerTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/resource/ResourceWebHandlerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -32,6 +32,7 @@ import reactor.test.StepVerifier; import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; import org.springframework.core.io.UrlResource; import org.springframework.core.io.buffer.DataBuffer; @@ -51,6 +52,7 @@ import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest; import org.springframework.web.testfixture.http.server.reactive.MockServerHttpResponse; import org.springframework.web.testfixture.server.MockServerWebExchange; +import org.springframework.web.util.UriUtils; import static java.nio.charset.StandardCharsets.UTF_8; import static org.assertj.core.api.Assertions.assertThat; @@ -232,6 +234,25 @@ public void getResourceWithRegisteredMediaType() throws Exception { assertResponseBody(exchange, "foo bar foo bar foo bar"); } + @Test + public void getResourceFromFileSystem() throws Exception { + String path = new ClassPathResource("", getClass()).getFile().getCanonicalPath() + .replace('\\', '/').replace("classes/java", "resources") + "/"; + + ResourceWebHandler handler = new ResourceWebHandler(); + handler.setLocations(Collections.singletonList(new FileSystemResource(path))); + handler.afterPropertiesSet(); + + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("")); + setPathWithinHandlerMapping(exchange, UriUtils.encodePath("test/foo with spaces.css", UTF_8)); + handler.handle(exchange).block(TIMEOUT); + + HttpHeaders headers = exchange.getResponse().getHeaders(); + assertThat(headers.getContentType()).isEqualTo(MediaType.parseMediaType("text/css")); + assertThat(headers.getContentLength()).isEqualTo(17); + assertResponseBody(exchange, "h1 { color:red; }"); + } + @Test // SPR-14577 public void getMediaTypeWithFavorPathExtensionOff() throws Exception { List paths = Collections.singletonList(new ClassPathResource("test/", getClass())); diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/RequestMappingInfoHandlerMappingTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/RequestMappingInfoHandlerMappingTests.java index dc505f80f3b9..cbc923abe1b2 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/RequestMappingInfoHandlerMappingTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/RequestMappingInfoHandlerMappingTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -37,6 +37,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; +import org.springframework.lang.Nullable; import org.springframework.stereotype.Controller; import org.springframework.util.ClassUtils; import org.springframework.util.MultiValueMap; @@ -44,6 +45,7 @@ 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.BindingContext; import org.springframework.web.reactive.HandlerMapping; @@ -192,10 +194,12 @@ public void getHandlerHttpOptions() { List allMethodExceptTrace = new ArrayList<>(Arrays.asList(HttpMethod.values())); allMethodExceptTrace.remove(HttpMethod.TRACE); - testHttpOptions("/foo", EnumSet.of(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.OPTIONS)); - testHttpOptions("/person/1", EnumSet.of(HttpMethod.PUT, HttpMethod.OPTIONS)); - testHttpOptions("/persons", EnumSet.copyOf(allMethodExceptTrace)); - testHttpOptions("/something", EnumSet.of(HttpMethod.PUT, HttpMethod.POST)); + testHttpOptions("/foo", EnumSet.of(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.OPTIONS), null); + testHttpOptions("/person/1", EnumSet.of(HttpMethod.PUT, HttpMethod.OPTIONS), null); + testHttpOptions("/persons", EnumSet.copyOf(allMethodExceptTrace), null); + testHttpOptions("/something", EnumSet.of(HttpMethod.PUT, HttpMethod.POST), null); + testHttpOptions("/qux", EnumSet.of(HttpMethod.PATCH,HttpMethod.GET,HttpMethod.HEAD,HttpMethod.OPTIONS), + new MediaType("foo", "bar")); } @Test @@ -313,6 +317,26 @@ public void handleMatchMatrixVariablesDecoding() { assertThat(uriVariables.get("cars")).isEqualTo("cars"); } + @Test + public void handlePatchUnsupportedMediaType() { + MockServerHttpRequest request = MockServerHttpRequest.patch("/qux") + .header("content-type", "application/xml") + .build(); + ServerWebExchange exchange = MockServerWebExchange.from(request); + Mono mono = this.handlerMapping.getHandler(exchange); + + StepVerifier.create(mono) + .expectErrorSatisfies(ex -> { + assertThat(ex).isInstanceOf(UnsupportedMediaTypeStatusException.class); + UnsupportedMediaTypeStatusException umtse = (UnsupportedMediaTypeStatusException) ex; + MediaType mediaType = new MediaType("foo", "bar"); + assertThat(umtse.getSupportedMediaTypes()).containsExactly(mediaType); + assertThat(umtse.getResponseHeaders().getAcceptPatch()).containsExactly(mediaType); + }) + .verify(); + + } + @SuppressWarnings("unchecked") private void assertError(Mono mono, final Class exceptionClass, final Consumer consumer) { @@ -332,7 +356,7 @@ private void testHttpMediaTypeNotSupportedException(String url) { assertError(mono, UnsupportedMediaTypeStatusException.class, ex -> assertThat(ex.getSupportedMediaTypes()).as("Invalid supported consumable media types").isEqualTo(Collections.singletonList(new MediaType("application", "xml")))); } - private void testHttpOptions(String requestURI, Set allowedMethods) { + private void testHttpOptions(String requestURI, Set allowedMethods, @Nullable MediaType acceptPatch) { ServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.options(requestURI)); HandlerMethod handlerMethod = (HandlerMethod) this.handlerMapping.getHandler(exchange).block(); @@ -346,7 +370,13 @@ private void testHttpOptions(String requestURI, Set allowedMethods) Object value = result.getReturnValue(); assertThat(value).isNotNull(); assertThat(value.getClass()).isEqualTo(HttpHeaders.class); - assertThat(((HttpHeaders) value).getAllow()).isEqualTo(allowedMethods); + + HttpHeaders headers = (HttpHeaders) value; + assertThat(headers.getAllow()).hasSameElementsAs(allowedMethods); + + if (acceptPatch != null && headers.getAllow().contains(HttpMethod.PATCH) ) { + assertThat(headers.getAcceptPatch()).containsExactly(acceptPatch); + } } private void testMediaTypeNotAcceptable(String url) { @@ -430,6 +460,16 @@ public HttpHeaders fooOptions() { return headers; } + @RequestMapping(value = "/qux", method = RequestMethod.GET, produces = "application/xml") + public String getBaz() { + return ""; + } + + @RequestMapping(value = "/qux", method = RequestMethod.PATCH, consumes = "foo/bar") + public void patchBaz(String value) { + } + + public void dummy() { } } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/CrossOriginAnnotationIntegrationTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/CrossOriginAnnotationIntegrationTests.java index aa7f287a4a2f..2439ff7926f0 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/CrossOriginAnnotationIntegrationTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/CrossOriginAnnotationIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -278,6 +278,20 @@ void ambiguousProducesPreflightRequest(HttpServer httpServer) throws Exception { assertThat(entity.getHeaders().getAccessControlAllowCredentials()).isTrue(); } + @ParameterizedHttpServerTest + void maxAgeWithDefaultOrigin(HttpServer httpServer) throws Exception { + startServer(httpServer); + + this.headers.add(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET"); + ResponseEntity entity = performOptions("/classAge", this.headers, String.class); + assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(entity.getHeaders().getAccessControlMaxAge()).isEqualTo(10); + + entity = performOptions("/methodAge", this.headers, String.class); + assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(entity.getHeaders().getAccessControlMaxAge()).isEqualTo(100); + } + @Configuration @EnableWebFlux @@ -395,4 +409,21 @@ public String baz() { } } + @RestController + @CrossOrigin(maxAge = 10) + private static class MaxAgeWithDefaultOriginController { + + @CrossOrigin + @GetMapping("/classAge") + String classAge() { + return "classAge"; + } + + @CrossOrigin(maxAge = 100) + @GetMapping("/methodAge") + String methodAge() { + return "methodAge"; + } + } + } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandlerTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandlerTests.java index 3374b17ed8b3..00998c0dd263 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandlerTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseEntityResultHandlerTests.java @@ -57,6 +57,7 @@ import org.springframework.web.reactive.HandlerResult; import org.springframework.web.reactive.accept.RequestedContentTypeResolver; import org.springframework.web.reactive.accept.RequestedContentTypeResolverBuilder; +import org.springframework.web.testfixture.http.server.reactive.MockServerHttpResponse; import org.springframework.web.testfixture.server.MockServerWebExchange; import static java.nio.charset.StandardCharsets.UTF_8; @@ -64,7 +65,6 @@ import static org.springframework.core.ResolvableType.forClassWithGenerics; import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.http.ResponseEntity.notFound; -import static org.springframework.http.ResponseEntity.ok; import static org.springframework.web.reactive.HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE; import static org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest.get; import static org.springframework.web.testfixture.method.ResolvableMethod.on; @@ -199,7 +199,7 @@ public void responseEntityHeaders() throws Exception { } @Test - public void handleResponseEntityWithNullBody() throws Exception { + public void handleResponseEntityWithNullBody() { Object returnValue = Mono.just(notFound().build()); MethodParameter type = on(TestController.class).resolveReturnType(Mono.class, entity(String.class)); HandlerResult result = handlerResult(returnValue, type); @@ -211,23 +211,23 @@ public void handleResponseEntityWithNullBody() throws Exception { } @Test - public void handleReturnTypes() throws Exception { - Object returnValue = ok("abc"); + public void handleReturnTypes() { + Object returnValue = ResponseEntity.ok("abc"); MethodParameter returnType = on(TestController.class).resolveReturnType(entity(String.class)); testHandle(returnValue, returnType); returnType = on(TestController.class).resolveReturnType(Object.class); testHandle(returnValue, returnType); - returnValue = Mono.just(ok("abc")); + returnValue = Mono.just(ResponseEntity.ok("abc")); returnType = on(TestController.class).resolveReturnType(Mono.class, entity(String.class)); testHandle(returnValue, returnType); - returnValue = Mono.just(ok("abc")); + returnValue = Mono.just(ResponseEntity.ok("abc")); returnType = on(TestController.class).resolveReturnType(Single.class, entity(String.class)); testHandle(returnValue, returnType); - returnValue = Mono.just(ok("abc")); + returnValue = Mono.just(ResponseEntity.ok("abc")); returnType = on(TestController.class).resolveReturnType(CompletableFuture.class, entity(String.class)); testHandle(returnValue, returnType); } @@ -239,7 +239,7 @@ public void handleReturnValueLastModified() throws Exception { long timestamp = currentTime.toEpochMilli(); MockServerWebExchange exchange = MockServerWebExchange.from(get("/path").ifModifiedSince(timestamp)); - ResponseEntity entity = ok().lastModified(oneMinAgo.toEpochMilli()).body("body"); + ResponseEntity entity = ResponseEntity.ok().lastModified(oneMinAgo.toEpochMilli()).body("body"); MethodParameter returnType = on(TestController.class).resolveReturnType(entity(String.class)); HandlerResult result = handlerResult(entity, returnType); this.resultHandler.handleResult(exchange, result).block(Duration.ofSeconds(5)); @@ -252,7 +252,7 @@ public void handleReturnValueEtag() throws Exception { String etagValue = "\"deadb33f8badf00d\""; MockServerWebExchange exchange = MockServerWebExchange.from(get("/path").ifNoneMatch(etagValue)); - ResponseEntity entity = ok().eTag(etagValue).body("body"); + ResponseEntity entity = ResponseEntity.ok().eTag(etagValue).body("body"); MethodParameter returnType = on(TestController.class).resolveReturnType(entity(String.class)); HandlerResult result = handlerResult(entity, returnType); this.resultHandler.handleResult(exchange, result).block(Duration.ofSeconds(5)); @@ -264,7 +264,7 @@ public void handleReturnValueEtag() throws Exception { public void handleReturnValueEtagInvalidIfNoneMatch() throws Exception { MockServerWebExchange exchange = MockServerWebExchange.from(get("/path").ifNoneMatch("unquoted")); - ResponseEntity entity = ok().eTag("\"deadb33f8badf00d\"").body("body"); + ResponseEntity entity = ResponseEntity.ok().eTag("\"deadb33f8badf00d\"").body("body"); MethodParameter returnType = on(TestController.class).resolveReturnType(entity(String.class)); HandlerResult result = handlerResult(entity, returnType); this.resultHandler.handleResult(exchange, result).block(Duration.ofSeconds(5)); @@ -285,7 +285,7 @@ public void handleReturnValueETagAndLastModified() throws Exception { .ifModifiedSince(currentTime.toEpochMilli()) ); - ResponseEntity entity = ok().eTag(eTag).lastModified(oneMinAgo.toEpochMilli()).body("body"); + ResponseEntity entity = ResponseEntity.ok().eTag(eTag).lastModified(oneMinAgo.toEpochMilli()).body("body"); MethodParameter returnType = on(TestController.class).resolveReturnType(entity(String.class)); HandlerResult result = handlerResult(entity, returnType); this.resultHandler.handleResult(exchange, result).block(Duration.ofSeconds(5)); @@ -306,7 +306,7 @@ public void handleReturnValueChangedETagAndLastModified() throws Exception { .ifModifiedSince(currentTime.toEpochMilli()) ); - ResponseEntity entity = ok().eTag(newEtag).lastModified(oneMinAgo.toEpochMilli()).body("body"); + ResponseEntity entity = ResponseEntity.ok().eTag(newEtag).lastModified(oneMinAgo.toEpochMilli()).body("body"); MethodParameter returnType = on(TestController.class).resolveReturnType(entity(String.class)); HandlerResult result = handlerResult(entity, returnType); this.resultHandler.handleResult(exchange, result).block(Duration.ofSeconds(5)); @@ -320,7 +320,7 @@ public void handleMonoWithWildcardBodyType() throws Exception { exchange.getAttributes().put(PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE, Collections.singleton(APPLICATION_JSON)); MethodParameter type = on(TestController.class).resolveReturnType(Mono.class, ResponseEntity.class); - HandlerResult result = new HandlerResult(new TestController(), Mono.just(ok().body("body")), type); + HandlerResult result = new HandlerResult(new TestController(), Mono.just(ResponseEntity.ok().body("body")), type); this.resultHandler.handleResult(exchange, result).block(Duration.ofSeconds(5)); @@ -399,7 +399,7 @@ public void handleWithProducibleContentTypeShouldFailWithServerError() { } @Test // gh-26212 - public void handleWithObjectMapperByTypeRegistration() throws Exception { + public void handleWithObjectMapperByTypeRegistration() { MediaType halFormsMediaType = MediaType.parseMediaType("application/prs.hal-forms+json"); MediaType halMediaType = MediaType.parseMediaType("application/hal+json"); @@ -429,6 +429,22 @@ public void handleWithObjectMapperByTypeRegistration() throws Exception { "}"); } + @Test // gh-24539 + public void malformedAcceptHeader() { + ResponseEntity value = ResponseEntity.badRequest().body("Foo"); + MethodParameter returnType = on(TestController.class).resolveReturnType(entity(String.class)); + HandlerResult result = handlerResult(value, returnType); + MockServerWebExchange exchange = MockServerWebExchange.from(get("/path").header("Accept", "null")); + + this.resultHandler.handleResult(exchange, result).block(Duration.ofSeconds(5)); + MockServerHttpResponse response = exchange.getResponse(); + response.setComplete().block(); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getHeaders().getContentType()).isNull(); + assertResponseBodyIsEmpty(exchange); + } + private void testHandle(Object returnValue, MethodParameter returnType) { MockServerWebExchange exchange = MockServerWebExchange.from(get("/path")); diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/socket/server/support/WebSocketUpgradeHandlerPredicateTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/socket/server/support/WebSocketUpgradeHandlerPredicateTests.java new file mode 100644 index 000000000000..2e06acd07232 --- /dev/null +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/socket/server/support/WebSocketUpgradeHandlerPredicateTests.java @@ -0,0 +1,97 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.socket.server.support; + +import java.util.Collections; + +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpHeaders; +import org.springframework.web.context.support.StaticWebApplicationContext; +import org.springframework.web.reactive.handler.SimpleUrlHandlerMapping; +import org.springframework.web.reactive.socket.WebSocketHandler; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest; +import org.springframework.web.testfixture.server.MockServerWebExchange; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Unit tests for and related to the use of {@link WebSocketUpgradeHandlerPredicate}. + * + * @author Rossen Stoyanchev + */ +public class WebSocketUpgradeHandlerPredicateTests { + + private final WebSocketUpgradeHandlerPredicate predicate = new WebSocketUpgradeHandlerPredicate(); + + private final WebSocketHandler webSocketHandler = mock(WebSocketHandler.class); + + ServerWebExchange httpGetExchange = + MockServerWebExchange.from(MockServerHttpRequest.get("/path")); + + ServerWebExchange httpPostExchange = + MockServerWebExchange.from(MockServerHttpRequest.post("/path")); + + ServerWebExchange webSocketExchange = + MockServerWebExchange.from(MockServerHttpRequest.get("/path").header(HttpHeaders.UPGRADE, "websocket")); + + + @Test + void match() { + assertThat(this.predicate.test(this.webSocketHandler, this.webSocketExchange)) + .as("Should match WebSocketHandler to WebSocket upgrade") + .isTrue(); + + assertThat(this.predicate.test(new Object(), this.httpGetExchange)) + .as("Should match non-WebSocketHandler to any request") + .isTrue(); + } + + @Test + void noMatch() { + assertThat(this.predicate.test(this.webSocketHandler, this.httpGetExchange)) + .as("Should not match WebSocket handler to HTTP GET") + .isFalse(); + + assertThat(this.predicate.test(this.webSocketHandler, this.httpPostExchange)) + .as("Should not match WebSocket handler to HTTP POST") + .isFalse(); + } + + @Test + void simpleUrlHandlerMapping() { + SimpleUrlHandlerMapping mapping = new SimpleUrlHandlerMapping(); + mapping.setUrlMap(Collections.singletonMap("/path", this.webSocketHandler)); + mapping.setApplicationContext(new StaticWebApplicationContext()); + + Object actual = mapping.getHandler(httpGetExchange).block(); + assertThat(actual).as("Should match HTTP GET by URL path").isSameAs(this.webSocketHandler); + + mapping.setHandlerPredicate(new WebSocketUpgradeHandlerPredicate()); + + actual = mapping.getHandler(this.httpGetExchange).block(); + assertThat(actual).as("Should not match if not a WebSocket upgrade").isNull(); + + actual = mapping.getHandler(this.httpPostExchange).block(); + assertThat(actual).as("Should not match if not a WebSocket upgrade").isNull(); + + actual = mapping.getHandler(this.webSocketExchange).block(); + assertThat(actual).as("Should match WebSocket upgrade").isSameAs(this.webSocketHandler); + } + +} diff --git a/spring-webflux/src/test/kotlin/org/springframework/web/reactive/function/client/WebClientExtensionsTests.kt b/spring-webflux/src/test/kotlin/org/springframework/web/reactive/function/client/WebClientExtensionsTests.kt index 6ab3c58ecdbf..ac127c0709e4 100644 --- a/spring-webflux/src/test/kotlin/org/springframework/web/reactive/function/client/WebClientExtensionsTests.kt +++ b/spring-webflux/src/test/kotlin/org/springframework/web/reactive/function/client/WebClientExtensionsTests.kt @@ -136,6 +136,25 @@ class WebClientExtensionsTests { } } + @Test + fun awaitBodyOrNull() { + val spec = mockk() + every { spec.bodyToMono() } returns Mono.just("foo") + runBlocking { + assertThat(spec.awaitBodyOrNull()).isEqualTo("foo") + } + } + + @Test + fun `awaitBodyOrNull of type Unit`() { + val spec = mockk() + val entity = mockk>() + every { spec.toBodilessEntity() } returns Mono.just(entity) + runBlocking { + assertThat(spec.awaitBodyOrNull()).isEqualTo(Unit) + } + } + @Test fun awaitBodilessEntity() { val spec = mockk() diff --git a/spring-webflux/src/test/resources/org/springframework/web/reactive/resource/test/foo with spaces.css b/spring-webflux/src/test/resources/org/springframework/web/reactive/resource/test/foo with spaces.css new file mode 100644 index 000000000000..e2f0b1c742ae --- /dev/null +++ b/spring-webflux/src/test/resources/org/springframework/web/reactive/resource/test/foo with spaces.css @@ -0,0 +1 @@ +h1 { color:red; } \ No newline at end of file diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/DispatcherServlet.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/DispatcherServlet.java index 6b9016e503ec..394780c95d5f 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/DispatcherServlet.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/DispatcherServlet.java @@ -986,8 +986,8 @@ private void logRequest(HttpServletRequest request) { String queryString = request.getQueryString(); String queryClause = (StringUtils.hasLength(queryString) ? "?" + queryString : ""); - String dispatchType = (!request.getDispatcherType().equals(DispatcherType.REQUEST) ? - "\"" + request.getDispatcherType().name() + "\" dispatch for " : ""); + String dispatchType = (!DispatcherType.REQUEST.equals(request.getDispatcherType()) ? + "\"" + request.getDispatcherType() + "\" dispatch for " : ""); String message = (dispatchType + request.getMethod() + " \"" + getRequestUri(request) + queryClause + "\", parameters={" + params + "}"); @@ -1185,7 +1185,7 @@ protected LocaleContext buildLocaleContext(final HttpServletRequest request) { protected HttpServletRequest checkMultipart(HttpServletRequest request) throws MultipartException { if (this.multipartResolver != null && this.multipartResolver.isMultipart(request)) { if (WebUtils.getNativeRequest(request, MultipartHttpServletRequest.class) != null) { - if (request.getDispatcherType().equals(DispatcherType.REQUEST)) { + if (DispatcherType.REQUEST.equals(request.getDispatcherType())) { logger.trace("Request already resolved to MultipartHttpServletRequest, e.g. by MultipartFilter"); } } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/FrameworkServlet.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/FrameworkServlet.java index e8016bd1200f..c8cddf01e42a 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/FrameworkServlet.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/FrameworkServlet.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -1084,8 +1084,8 @@ private void logResult(HttpServletRequest request, HttpServletResponse response, return; } - String dispatchType = request.getDispatcherType().name(); - boolean initialDispatch = request.getDispatcherType().equals(DispatcherType.REQUEST); + DispatcherType dispatchType = request.getDispatcherType(); + boolean initialDispatch = DispatcherType.REQUEST.equals(request.getDispatcherType()); if (failureCause != null) { if (!initialDispatch) { diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/CorsRegistration.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/CorsRegistration.java index 0de5ecae5dbc..f60ff3770a0a 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/CorsRegistration.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/CorsRegistration.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -60,7 +60,7 @@ public CorsRegistration allowedOrigins(String... origins) { /** * Alternative to {@link #allowCredentials} that supports origins declared * via wildcard patterns. Please, see - * @link CorsConfiguration#setAllowedOriginPatterns(List)} for details. + * {@link CorsConfiguration#setAllowedOriginPatterns(List)} for details. *

    By default this is not set. * @since 5.3 */ diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java index 545f513dfb97..b1d14b2e28af 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java @@ -129,6 +129,8 @@ * ordered at 1 to map URL paths directly to view names. *

  • {@link BeanNameUrlHandlerMapping} * ordered at 2 to map URL paths to controller bean names. + *
  • {@link RouterFunctionMapping} + * ordered at 3 to map {@linkplain org.springframework.web.servlet.function.RouterFunction router functions}. *
  • {@link HandlerMapping} * ordered at {@code Integer.MAX_VALUE-1} to serve static resource requests. *
  • {@link HandlerMapping} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/support/RouterFunctionMapping.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/support/RouterFunctionMapping.java index 040679b595dc..ebaae4460d92 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/function/support/RouterFunctionMapping.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/function/support/RouterFunctionMapping.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -153,14 +153,31 @@ private void initRouterFunction() { (this.detectHandlerFunctionsInAncestorContexts ? BeanFactoryUtils.beansOfTypeIncludingAncestors(applicationContext, RouterFunction.class) : applicationContext.getBeansOfType(RouterFunction.class)); - List routerFunctions = new ArrayList<>(beans.values()); - if (!CollectionUtils.isEmpty(routerFunctions) && logger.isInfoEnabled()) { - routerFunctions.forEach(routerFunction -> logger.info("Mapped " + routerFunction)); + this.routerFunction = routerFunctions.stream().reduce(RouterFunction::andOther).orElse(null); + logRouterFunctions(routerFunctions); + } + + @SuppressWarnings("rawtypes") + private void logRouterFunctions(List routerFunctions) { + if (mappingsLogger.isDebugEnabled()) { + routerFunctions.forEach(function -> mappingsLogger.debug("Mapped " + function)); + } + else if (logger.isDebugEnabled()) { + int total = routerFunctions.size(); + String message = total + " RouterFunction(s) in " + formatMappingName(); + if (logger.isTraceEnabled()) { + if (total > 0) { + routerFunctions.forEach(function -> logger.trace("Mapped " + function)); + } + else { + logger.trace(message); + } + } + else if (total > 0) { + logger.debug(message); + } } - this.routerFunction = routerFunctions.stream() - .reduce(RouterFunction::andOther) - .orElse(null); } /** diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractDetectingUrlHandlerMapping.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractDetectingUrlHandlerMapping.java index a254fff84d44..a5f70384fb77 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractDetectingUrlHandlerMapping.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractDetectingUrlHandlerMapping.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2012 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -82,7 +82,10 @@ protected void detectHandlers() throws BeansException { } } - if ((logger.isDebugEnabled() && !getHandlerMap().isEmpty()) || logger.isTraceEnabled()) { + if (mappingsLogger.isDebugEnabled()) { + mappingsLogger.debug(formatMappingName() + " " + getHandlerMap()); + } + else if ((logger.isDebugEnabled() && !getHandlerMap().isEmpty()) || logger.isTraceEnabled()) { logger.debug("Detected " + getHandlerMap().size() + " mappings in " + formatMappingName()); } } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerMapping.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerMapping.java index a749d2f99afd..900b7881be90 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerMapping.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerMapping.java @@ -26,10 +26,13 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import org.apache.commons.logging.Log; + import org.springframework.beans.BeansException; import org.springframework.beans.factory.BeanFactoryUtils; import org.springframework.beans.factory.BeanNameAware; import org.springframework.core.Ordered; +import org.springframework.core.log.LogDelegateFactory; import org.springframework.http.server.RequestPath; import org.springframework.lang.Nullable; import org.springframework.util.AntPathMatcher; @@ -75,6 +78,11 @@ public abstract class AbstractHandlerMapping extends WebApplicationObjectSupport implements HandlerMapping, Ordered, BeanNameAware { + /** Dedicated "hidden" logger for request mappings. */ + protected final Log mappingsLogger = + LogDelegateFactory.getHiddenLog(HandlerMapping.class.getName() + ".Mappings"); + + @Nullable private Object defaultHandler; @@ -210,7 +218,6 @@ public void setRemoveSemicolonContent(boolean removeSemicolonContent) { *

    Note: This property is mutually exclusive with and * ignored when {@link #setPatternParser(PathPatternParser)} is set. */ - @SuppressWarnings("deprecation") public void setUrlPathHelper(UrlPathHelper urlPathHelper) { Assert.notNull(urlPathHelper, "UrlPathHelper must not be null"); this.urlPathHelper = urlPathHelper; @@ -358,7 +365,7 @@ public void setBeanName(String name) { } protected String formatMappingName() { - return this.beanName != null ? "'" + this.beanName + "'" : ""; + return this.beanName != null ? "'" + this.beanName + "'" : getClass().getName(); } @@ -511,7 +518,7 @@ public final HandlerExecutionChain getHandler(HttpServletRequest request) throws if (logger.isTraceEnabled()) { logger.trace("Mapped to " + handler); } - else if (logger.isDebugEnabled() && !request.getDispatcherType().equals(DispatcherType.ASYNC)) { + else if (logger.isDebugEnabled() && !DispatcherType.ASYNC.equals(request.getDispatcherType())) { logger.debug("Mapped to " + executionChain.getHandler()); } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerMethodMapping.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerMethodMapping.java index e346db9f53b5..e15df8846dde 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerMethodMapping.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerMethodMapping.java @@ -290,6 +290,9 @@ protected void detectHandlerMethods(Object handler) { if (logger.isTraceEnabled()) { logger.trace(formatMappings(userType, methods)); } + else if (mappingsLogger.isDebugEnabled()) { + mappingsLogger.debug(formatMappings(userType, methods)); + } methods.forEach((method, mapping) -> { Method invocableMethod = AopUtils.selectInvocableMethod(method, userType); registerHandlerMethod(handler, invocableMethod, mapping); @@ -793,10 +796,6 @@ public Match(T mapping, MappingRegistration registration) { this.registration = registration; } - public T getMapping() { - return this.mapping; - } - public HandlerMethod getHandlerMethod() { return this.registration.getHandlerMethod(); } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractUrlHandlerMapping.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractUrlHandlerMapping.java index 589c3aecd58a..2f7be313ca98 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractUrlHandlerMapping.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractUrlHandlerMapping.java @@ -206,7 +206,7 @@ protected Object lookupHandler( if (matches.size() > 1) { matches.sort(PathPattern.SPECIFICITY_COMPARATOR); if (logger.isTraceEnabled()) { - logger.debug("Matching patterns " + matches); + logger.trace("Matching patterns " + matches); } } PathPattern pattern = matches.get(0); diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/MappedInterceptor.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/MappedInterceptor.java index 3b1011fba46c..01e0961984e2 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/MappedInterceptor.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/MappedInterceptor.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -34,6 +34,7 @@ import org.springframework.web.util.UrlPathHelper; import org.springframework.web.util.pattern.PathPattern; import org.springframework.web.util.pattern.PathPatternParser; +import org.springframework.web.util.pattern.PatternParseException; /** * Wraps a {@link HandlerInterceptor} and uses URL patterns to determine whether @@ -64,10 +65,10 @@ public final class MappedInterceptor implements HandlerInterceptor { @Nullable - private final PathPattern[] includePatterns; + private final PatternAdapter[] includePatterns; @Nullable - private final PathPattern[] excludePatterns; + private final PatternAdapter[] excludePatterns; private PathMatcher pathMatcher = defaultPathMatcher; @@ -88,21 +89,11 @@ public final class MappedInterceptor implements HandlerInterceptor { public MappedInterceptor(@Nullable String[] includePatterns, @Nullable String[] excludePatterns, HandlerInterceptor interceptor, @Nullable PathPatternParser parser) { - this.includePatterns = initPatterns(includePatterns, parser); - this.excludePatterns = initPatterns(excludePatterns, parser); + this.includePatterns = PatternAdapter.initPatterns(includePatterns, parser); + this.excludePatterns = PatternAdapter.initPatterns(excludePatterns, parser); this.interceptor = interceptor; } - @Nullable - private static PathPattern[] initPatterns( - @Nullable String[] patterns, @Nullable PathPatternParser parser) { - - if (ObjectUtils.isEmpty(patterns)) { - return null; - } - parser = (parser != null ? parser : PathPatternParser.defaultInstance); - return Arrays.stream(patterns).map(parser::parse).toArray(PathPattern[]::new); - } /** * Variant of @@ -151,7 +142,7 @@ public MappedInterceptor(@Nullable String[] includePatterns, @Nullable String[] @Nullable public String[] getPathPatterns() { return (!ObjectUtils.isEmpty(this.includePatterns) ? - Arrays.stream(this.includePatterns).map(PathPattern::getPatternString).toArray(String[]::new) : + Arrays.stream(this.includePatterns).map(PatternAdapter::getPatternString).toArray(String[]::new) : null); } @@ -199,8 +190,8 @@ public boolean matches(HttpServletRequest request) { } boolean isPathContainer = (path instanceof PathContainer); if (!ObjectUtils.isEmpty(this.excludePatterns)) { - for (PathPattern pattern : this.excludePatterns) { - if (matchPattern(path, isPathContainer, pattern)) { + for (PatternAdapter adapter : this.excludePatterns) { + if (adapter.match(path, isPathContainer, this.pathMatcher)) { return false; } } @@ -208,20 +199,14 @@ public boolean matches(HttpServletRequest request) { if (ObjectUtils.isEmpty(this.includePatterns)) { return true; } - for (PathPattern pattern : this.includePatterns) { - if (matchPattern(path, isPathContainer, pattern)) { + for (PatternAdapter adapter : this.includePatterns) { + if (adapter.match(path, isPathContainer, this.pathMatcher)) { return true; } } return false; } - private boolean matchPattern(Object path, boolean isPathContainer, PathPattern pattern) { - return (isPathContainer ? - pattern.matches((PathContainer) path) : - this.pathMatcher.match(pattern.getPatternString(), (String) path)); - } - /** * Determine a match for the given lookup path. * @param lookupPath the current request path @@ -233,8 +218,8 @@ private boolean matchPattern(Object path, boolean isPathContainer, PathPattern p public boolean matches(String lookupPath, PathMatcher pathMatcher) { pathMatcher = (this.pathMatcher != defaultPathMatcher ? this.pathMatcher : pathMatcher); if (!ObjectUtils.isEmpty(this.excludePatterns)) { - for (PathPattern pattern : this.excludePatterns) { - if (pathMatcher.match(pattern.getPatternString(), lookupPath)) { + for (PatternAdapter adapter : this.excludePatterns) { + if (pathMatcher.match(adapter.getPatternString(), lookupPath)) { return false; } } @@ -242,8 +227,8 @@ public boolean matches(String lookupPath, PathMatcher pathMatcher) { if (ObjectUtils.isEmpty(this.includePatterns)) { return true; } - for (PathPattern pattern : this.includePatterns) { - if (pathMatcher.match(pattern.getPatternString(), lookupPath)) { + for (PatternAdapter adapter : this.includePatterns) { + if (pathMatcher.match(adapter.getPatternString(), lookupPath)) { return true; } } @@ -274,4 +259,64 @@ public void afterCompletion(HttpServletRequest request, HttpServletResponse resp this.interceptor.afterCompletion(request, response, handler, ex); } + + /** + * Contains both the parsed {@link PathPattern} and the raw String pattern, + * and uses the former when the cached path is {@link PathContainer} or the + * latter otherwise. If the pattern cannot be parsed due to unsupported + * syntax, then {@link PathMatcher} is used for all requests. + * @since 5.3.6 + */ + private static class PatternAdapter { + + private final String patternString; + + @Nullable + private final PathPattern pathPattern; + + + public PatternAdapter(String pattern, @Nullable PathPatternParser parser) { + this.patternString = pattern; + this.pathPattern = initPathPattern(pattern, parser); + } + + @Nullable + private static PathPattern initPathPattern(String pattern, @Nullable PathPatternParser parser) { + try { + return (parser != null ? parser : PathPatternParser.defaultInstance).parse(pattern); + } + catch (PatternParseException ex) { + return null; + } + } + + public String getPatternString() { + return this.patternString; + } + + public boolean match(Object path, boolean isPathContainer, PathMatcher pathMatcher) { + if (isPathContainer) { + PathContainer pathContainer = (PathContainer) path; + if (this.pathPattern != null) { + return this.pathPattern.matches(pathContainer); + } + String lookupPath = pathContainer.value(); + path = UrlPathHelper.defaultInstance.removeSemicolonContent(lookupPath); + } + return pathMatcher.match(this.patternString, (String) path); + } + + @Nullable + public static PatternAdapter[] initPatterns( + @Nullable String[] patterns, @Nullable PathPatternParser parser) { + + if (ObjectUtils.isEmpty(patterns)) { + return null; + } + return Arrays.stream(patterns) + .map(pattern -> new PatternAdapter(pattern, parser)) + .toArray(PatternAdapter[]::new); + } + } + } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/MatchableHandlerMapping.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/MatchableHandlerMapping.java index eab017a4ac8f..85ef3ec17da0 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/MatchableHandlerMapping.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/MatchableHandlerMapping.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -46,7 +46,7 @@ default PathPatternParser getPatternParser() { /** * Determine whether the request matches the given pattern. Use this method * when {@link #getPatternParser()} returns {@code null} which means that the - * {@code HandlerMapping} is uses String pattern matching. + * {@code HandlerMapping} is using String pattern matching. * @param request the current request * @param pattern the pattern to match * @return the result from request matching, or {@code null} if none diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/SimpleUrlHandlerMapping.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/SimpleUrlHandlerMapping.java index b39b46ecb6bd..8e37bbf337fd 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/SimpleUrlHandlerMapping.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/handler/SimpleUrlHandlerMapping.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -161,17 +161,31 @@ protected void registerHandlers(Map urlMap) throws BeansExceptio } registerHandler(url, handler); }); - if (logger.isDebugEnabled()) { - List patterns = new ArrayList<>(); - if (getRootHandler() != null) { - patterns.add("/"); - } - if (getDefaultHandler() != null) { - patterns.add("/**"); - } - patterns.addAll(getHandlerMap().keySet()); - logger.debug("Patterns " + patterns + " in " + formatMappingName()); + logMappings(); + } + } + + private void logMappings() { + if (mappingsLogger.isDebugEnabled()) { + Map map = new LinkedHashMap<>(getHandlerMap()); + if (getRootHandler() != null) { + map.put("/", getRootHandler()); + } + if (getDefaultHandler() != null) { + map.put("/**", getDefaultHandler()); + } + mappingsLogger.debug(formatMappingName() + " " + map); + } + else if (logger.isDebugEnabled()) { + List patterns = new ArrayList<>(); + if (getRootHandler() != null) { + patterns.add("/"); + } + if (getDefaultHandler() != null) { + patterns.add("/**"); } + patterns.addAll(getHandlerMap().keySet()); + logger.debug("Patterns " + patterns + " in " + formatMappingName()); } } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/i18n/AcceptHeaderLocaleResolver.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/i18n/AcceptHeaderLocaleResolver.java index 2e59931e97d3..e5c027e96831 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/i18n/AcceptHeaderLocaleResolver.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/i18n/AcceptHeaderLocaleResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -72,7 +72,7 @@ public List getSupportedLocales() { /** * Configure a fixed default locale to fall back on if the request does not * have an "Accept-Language" header. - *

    By default this is not set in which case when there is "Accept-Language" + *

    By default this is not set in which case when there is no "Accept-Language" * header, the default locale for the server is used as defined in * {@link HttpServletRequest#getLocale()}. * @param defaultLocale the default locale to use diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/RequestMappingInfoHandlerMapping.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/RequestMappingInfoHandlerMapping.java index a52e6c7db4ba..254deb91282f 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/RequestMappingInfoHandlerMapping.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/RequestMappingInfoHandlerMapping.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -244,7 +244,8 @@ protected HandlerMethod handleNoMatch( if (helper.hasMethodsMismatch()) { Set methods = helper.getAllowedMethods(); if (HttpMethod.OPTIONS.matches(request.getMethod())) { - HttpOptionsHandler handler = new HttpOptionsHandler(methods); + Set mediaTypes = helper.getConsumablePatchMediaTypes(); + HttpOptionsHandler handler = new HttpOptionsHandler(methods, mediaTypes); return new HandlerMethod(handler, HTTP_OPTIONS_HANDLE_METHOD); } throw new HttpRequestMethodNotSupportedException(request.getMethod(), methods); @@ -411,6 +412,21 @@ public List getParamConditions() { return result; } + /** + * Return declared "consumable" types but only among those that have + * PATCH specified, or that have no methods at all. + */ + public Set getConsumablePatchMediaTypes() { + Set result = new LinkedHashSet<>(); + for (PartialMatch match : this.partialMatches) { + Set methods = match.getInfo().getMethodsCondition().getMethods(); + if (methods.isEmpty() || methods.contains(RequestMethod.PATCH)) { + result.addAll(match.getInfo().getConsumesCondition().getConsumableMediaTypes()); + } + } + return result; + } + /** * Container for a RequestMappingInfo that matches the URL path at least. @@ -475,8 +491,9 @@ private static class HttpOptionsHandler { private final HttpHeaders headers = new HttpHeaders(); - public HttpOptionsHandler(Set declaredMethods) { + public HttpOptionsHandler(Set declaredMethods, Set acceptPatch) { this.headers.setAllow(initAllowedHttpMethods(declaredMethods)); + this.headers.setAcceptPatch(new ArrayList<>(acceptPatch)); } private static Set initAllowedHttpMethods(Set declaredMethods) { diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/AbstractMessageConverterMethodProcessor.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/AbstractMessageConverterMethodProcessor.java index 94748eb7d925..158a33b9c918 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/AbstractMessageConverterMethodProcessor.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/AbstractMessageConverterMethodProcessor.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -213,7 +213,20 @@ protected void writeWithMessageConverters(@Nullable T value, MethodParameter } else { HttpServletRequest request = inputMessage.getServletRequest(); - List acceptableTypes = getAcceptableMediaTypes(request); + List acceptableTypes; + try { + acceptableTypes = getAcceptableMediaTypes(request); + } + catch (HttpMediaTypeNotAcceptableException ex) { + int series = outputMessage.getServletResponse().getStatus() / 100; + if (body == null || series == 4 || series == 5) { + if (logger.isDebugEnabled()) { + logger.debug("Ignoring error response content (if any). " + ex); + } + return; + } + throw ex; + } List producibleTypes = getProducibleMediaTypes(request, valueType, targetType); if (body != null && producibleTypes.isEmpty()) { diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/MatrixVariableMethodArgumentResolver.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/MatrixVariableMethodArgumentResolver.java index 8009da6daa27..cba21ac3dfe5 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/MatrixVariableMethodArgumentResolver.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/MatrixVariableMethodArgumentResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -126,6 +126,12 @@ protected void handleMissingValue(String name, MethodParameter parameter) throws throw new MissingMatrixVariableException(name, parameter); } + @Override + protected void handleMissingValueAfterConversion( + String name, MethodParameter parameter, NativeWebRequest request) throws Exception { + + throw new MissingMatrixVariableException(name, parameter, true); + } private static final class MatrixVariableNamedValueInfo extends NamedValueInfo { diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/MvcUriComponentsBuilder.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/MvcUriComponentsBuilder.java index fcda20d4688d..7c60bee6675a 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/MvcUriComponentsBuilder.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/MvcUriComponentsBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -97,7 +97,9 @@ */ public class MvcUriComponentsBuilder { - /** Well-known name for the {@link CompositeUriComponentsContributor} object in the bean factory. */ + /** + * Well-known name for the {@link CompositeUriComponentsContributor} object in the bean factory. + */ public static final String MVC_URI_COMPONENTS_CONTRIBUTOR_BEAN_NAME = "mvcUriComponentsContributor"; @@ -716,7 +718,7 @@ private static class ControllerMethodInvocationInterceptor @Override @Nullable - public Object intercept(Object obj, Method method, Object[] args, @Nullable MethodProxy proxy) { + public Object intercept(@Nullable Object obj, Method method, Object[] args, @Nullable MethodProxy proxy) { switch (method.getName()) { case "getControllerType": return this.controllerType; case "getControllerMethod": return this.controllerMethod; diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/PathVariableMethodArgumentResolver.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/PathVariableMethodArgumentResolver.java index 526eb0355a23..21a9fdb98480 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/PathVariableMethodArgumentResolver.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/PathVariableMethodArgumentResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -101,6 +101,13 @@ protected void handleMissingValue(String name, MethodParameter parameter) throws throw new MissingPathVariableException(name, parameter); } + @Override + protected void handleMissingValueAfterConversion( + String name, MethodParameter parameter, NativeWebRequest request) throws Exception { + + throw new MissingPathVariableException(name, parameter, true); + } + @Override @SuppressWarnings("unchecked") protected void handleResolvedValue(@Nullable Object arg, String name, MethodParameter parameter, diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestBodyAdvice.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestBodyAdvice.java index c8c7bc4c7425..a50872583b88 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestBodyAdvice.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestBodyAdvice.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2017 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -57,7 +57,7 @@ boolean supports(MethodParameter methodParameter, Type targetType, * @param targetType the target type, not necessarily the same as the method * parameter type, e.g. for {@code HttpEntity}. * @param converterType the converter used to deserialize the body - * @return the input request or a new instance, never {@code null} + * @return the input request or a new instance (never {@code null}) */ HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class> converterType) throws IOException; @@ -83,8 +83,8 @@ Object afterBodyRead(Object body, HttpInputMessage inputMessage, MethodParameter * @param targetType the target type, not necessarily the same as the method * parameter type, e.g. for {@code HttpEntity}. * @param converterType the selected converter type - * @return the value to use or {@code null} which may then raise an - * {@code HttpMessageNotReadableException} if the argument is required. + * @return the value to use, or {@code null} which may then raise an + * {@code HttpMessageNotReadableException} if the argument is required */ @Nullable Object handleEmptyBody(@Nullable Object body, HttpInputMessage inputMessage, MethodParameter parameter, diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestBodyAdviceAdapter.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestBodyAdviceAdapter.java index 0e8ade00d8c7..cdb68a7975a2 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestBodyAdviceAdapter.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestBodyAdviceAdapter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2015 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package org.springframework.web.servlet.mvc.method.annotation; import java.io.IOException; @@ -25,10 +26,10 @@ /** * A convenient starting point for implementing - * {@link org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice - * ResponseBodyAdvice} with default method implementations. + * {@link org.springframework.web.servlet.mvc.method.annotation.RequestBodyAdvice + * RequestBodyAdvice} with default method implementations. * - *

    Sub-classes are required to implement {@link #supports} to return true + *

    Subclasses are required to implement {@link #supports} to return true * depending on when the advice applies. * * @author Rossen Stoyanchev @@ -41,8 +42,7 @@ public abstract class RequestBodyAdviceAdapter implements RequestBodyAdvice { */ @Override public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter, - Type targetType, Class> converterType) - throws IOException { + Type targetType, Class> converterType) throws IOException { return inputMessage; } @@ -62,9 +62,8 @@ public Object afterBodyRead(Object body, HttpInputMessage inputMessage, MethodPa */ @Override @Nullable - public Object handleEmptyBody(@Nullable Object body, HttpInputMessage inputMessage, - MethodParameter parameter, Type targetType, - Class> converterType) { + public Object handleEmptyBody(@Nullable Object body, HttpInputMessage inputMessage, MethodParameter parameter, + Type targetType, Class> converterType) { return body; } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.java index 4a3b98057c80..1da7ca1ed8b3 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -476,7 +476,7 @@ else if (!allowCredentials.isEmpty()) { "or an empty string (\"\"): current value is [" + allowCredentials + "]"); } - if (annotation.maxAge() >= 0 && config.getMaxAge() == null) { + if (annotation.maxAge() >= 0 ) { config.setMaxAge(annotation.maxAge()); } } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandler.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandler.java index 5db290d60723..f706afe2d4c6 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandler.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -231,6 +231,12 @@ protected ResponseEntity handleHttpMediaTypeNotSupported( List mediaTypes = ex.getSupportedMediaTypes(); if (!CollectionUtils.isEmpty(mediaTypes)) { headers.setAccept(mediaTypes); + if (request instanceof ServletWebRequest) { + ServletWebRequest servletWebRequest = (ServletWebRequest) request; + if (HttpMethod.PATCH.equals(servletWebRequest.getHttpMethod())) { + headers.setAcceptPatch(mediaTypes); + } + } } return handleExceptionInternal(ex, null, headers, status, request); diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/support/DefaultHandlerExceptionResolver.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/support/DefaultHandlerExceptionResolver.java index a5f1d79adc63..542f0ae8f87d 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/support/DefaultHandlerExceptionResolver.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/support/DefaultHandlerExceptionResolver.java @@ -281,6 +281,9 @@ protected ModelAndView handleHttpMediaTypeNotSupported(HttpMediaTypeNotSupported List mediaTypes = ex.getSupportedMediaTypes(); if (!CollectionUtils.isEmpty(mediaTypes)) { response.setHeader("Accept", MediaType.toString(mediaTypes)); + if (request.getMethod().equals("PATCH")) { + response.setHeader("Accept-Patch", MediaType.toString(mediaTypes)); + } } response.sendError(HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE); return new ModelAndView(); diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/PathResourceResolver.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/PathResourceResolver.java index 82dea1581d6e..c6b364992741 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/PathResourceResolver.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/PathResourceResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -33,9 +33,11 @@ import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.Resource; import org.springframework.core.io.UrlResource; +import org.springframework.http.server.PathContainer; import org.springframework.lang.Nullable; import org.springframework.util.StringUtils; import org.springframework.web.context.support.ServletContextResource; +import org.springframework.web.util.ServletRequestPathUtils; import org.springframework.web.util.UriUtils; import org.springframework.web.util.UrlPathHelper; @@ -151,7 +153,7 @@ private Resource getResource(String resourcePath, @Nullable HttpServletRequest r for (Resource location : locations) { try { - String pathToUse = encodeIfNecessary(resourcePath, request, location); + String pathToUse = encodeOrDecodeIfNecessary(resourcePath, request, location); Resource resource = getResource(pathToUse, location); if (resource != null) { return resource; @@ -255,8 +257,11 @@ else if (resource instanceof ServletContextResource) { return (resourcePath.startsWith(locationPath) && !isInvalidEncodedPath(resourcePath)); } - private String encodeIfNecessary(String path, @Nullable HttpServletRequest request, Resource location) { - if (shouldEncodeRelativePath(location) && request != null) { + private String encodeOrDecodeIfNecessary(String path, @Nullable HttpServletRequest request, Resource location) { + if (shouldDecodeRelativePath(location, request)) { + return UriUtils.decode(path, StandardCharsets.UTF_8); + } + else if (shouldEncodeRelativePath(location) && request != null) { Charset charset = this.locationCharsets.getOrDefault(location, StandardCharsets.UTF_8); StringBuilder sb = new StringBuilder(); StringTokenizer tokenizer = new StringTokenizer(path, "/"); @@ -275,8 +280,15 @@ private String encodeIfNecessary(String path, @Nullable HttpServletRequest reque } } + private boolean shouldDecodeRelativePath(Resource location, @Nullable HttpServletRequest request) { + return (!(location instanceof UrlResource) && request != null && + ServletRequestPathUtils.hasCachedPath(request) && + ServletRequestPathUtils.getCachedPath(request) instanceof PathContainer); + } + private boolean shouldEncodeRelativePath(Resource location) { - return (location instanceof UrlResource && this.urlPathHelper != null && this.urlPathHelper.isUrlDecode()); + return (location instanceof UrlResource && + this.urlPathHelper != null && this.urlPathHelper.isUrlDecode()); } private boolean isInvalidEncodedPath(String resourcePath) { diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandler.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandler.java index 260b443863e1..ed49cf3cd201 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandler.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandler.java @@ -800,6 +800,10 @@ protected void setHeaders(HttpServletResponse response, Resource resource, @Null @Override public String toString() { - return "ResourceHttpRequestHandler " + getLocations(); + return "ResourceHttpRequestHandler " + + getLocations().toString() + .replaceAll("class path resource", "Classpath") + .replaceAll("ServletContext resource", "ServletContext"); } + } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/ResourceUrlProvider.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/ResourceUrlProvider.java index 67c2ad455b89..c1f926cd86d9 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/ResourceUrlProvider.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/ResourceUrlProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -27,7 +27,9 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.springframework.beans.BeansException; import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; import org.springframework.context.ApplicationListener; import org.springframework.context.event.ContextRefreshedEvent; import org.springframework.core.annotation.AnnotationAwareOrderComparator; @@ -47,12 +49,16 @@ * {@code ResourceHttpRequestHandler}s to make its decisions. * * @author Rossen Stoyanchev + * @author Brian Clozel * @since 4.1 */ -public class ResourceUrlProvider implements ApplicationListener { +public class ResourceUrlProvider implements ApplicationListener, ApplicationContextAware { protected final Log logger = LogFactory.getLog(getClass()); + @Nullable + private ApplicationContext applicationContext; + private UrlPathHelper urlPathHelper = UrlPathHelper.defaultInstance; private PathMatcher pathMatcher = new AntPathMatcher(); @@ -62,6 +68,11 @@ public class ResourceUrlProvider implements ApplicationListener beans = appContext.getBeansOfType(SimpleUrlHandlerMapping.class); List mappings = new ArrayList<>(beans.values()); @@ -215,7 +226,6 @@ private int getEndPathIndex(String lookupPath) { */ @Nullable public final String getForLookupPath(String lookupPath) { - // Clean duplicate slashes or pathWithinPattern won't match lookupPath String previous; do { diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/handler/MappedInterceptorTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/handler/MappedInterceptorTests.java index 6501d808c11a..d404c32346ab 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/handler/MappedInterceptorTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/handler/MappedInterceptorTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -88,6 +88,15 @@ void includeAndExcludePatterns(Function requestF assertThat(interceptor.matches(requestFactory.apply("/admin/foo"))).isFalse(); } + @PathPatternsParameterizedTest // gh-26690 + void includePatternWithFallbackOnPathMatcher(Function requestFactory) { + MappedInterceptor interceptor = new MappedInterceptor(new String[] { "/path1/**/path2" }, null, delegate); + + assertThat(interceptor.matches(requestFactory.apply("/path1/foo/bar/path2"))).isTrue(); + assertThat(interceptor.matches(requestFactory.apply("/path1/foo/bar/path3"))).isFalse(); + assertThat(interceptor.matches(requestFactory.apply("/path3/foo/bar/path2"))).isFalse(); + } + @PathPatternsParameterizedTest void customPathMatcher(Function requestFactory) { MappedInterceptor interceptor = new MappedInterceptor(new String[] { "/foo/[0-9]*" }, null, delegate); diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/RequestMappingInfoHandlerMappingTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/RequestMappingInfoHandlerMappingTests.java index 8b8afa81c9fc..32277c53bee5 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/RequestMappingInfoHandlerMappingTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/RequestMappingInfoHandlerMappingTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,6 +22,7 @@ import java.util.HashSet; import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; import java.util.stream.Stream; import javax.servlet.http.HttpServletRequest; @@ -31,8 +32,10 @@ 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.RequestPath; +import org.springframework.lang.Nullable; import org.springframework.stereotype.Controller; import org.springframework.util.MultiValueMap; import org.springframework.web.HttpMediaTypeNotAcceptableException; @@ -177,10 +180,11 @@ void getHandlerMediaTypeNotSupported(TestRequestMappingInfoHandlerMapping mappin @PathPatternsParameterizedTest void getHandlerHttpOptions(TestRequestMappingInfoHandlerMapping mapping) throws Exception { - testHttpOptions(mapping, "/foo", "GET,HEAD,OPTIONS"); - testHttpOptions(mapping, "/person/1", "PUT,OPTIONS"); - testHttpOptions(mapping, "/persons", "GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS"); - testHttpOptions(mapping, "/something", "PUT,POST"); + testHttpOptions(mapping, "/foo", "GET,HEAD,OPTIONS", null); + testHttpOptions(mapping, "/person/1", "PUT,OPTIONS", null); + testHttpOptions(mapping, "/persons", "GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS", null); + testHttpOptions(mapping, "/something", "PUT,POST", null); + testHttpOptions(mapping, "/qux", "PATCH,GET,HEAD,OPTIONS", new MediaType("foo", "bar")); } @PathPatternsParameterizedTest @@ -401,8 +405,8 @@ private void testHttpMediaTypeNotSupportedException(TestRequestMappingInfoHandle .satisfies(ex -> assertThat(ex.getSupportedMediaTypes()).containsExactly(MediaType.APPLICATION_XML)); } - private void testHttpOptions( - TestRequestMappingInfoHandlerMapping mapping, String requestURI, String allowHeader) throws Exception { + private void testHttpOptions(TestRequestMappingInfoHandlerMapping mapping, String requestURI, + String allowHeader, @Nullable MediaType acceptPatch) throws Exception { MockHttpServletRequest request = new MockHttpServletRequest("OPTIONS", requestURI); HandlerMethod handlerMethod = getHandler(mapping, request); @@ -413,7 +417,15 @@ private void testHttpOptions( assertThat(result).isNotNull(); assertThat(result.getClass()).isEqualTo(HttpHeaders.class); - assertThat(((HttpHeaders) result).getFirst("Allow")).isEqualTo(allowHeader); + HttpHeaders headers = (HttpHeaders) result; + Set allowedMethods = Arrays.stream(allowHeader.split(",")) + .map(HttpMethod::valueOf) + .collect(Collectors.toSet()); + assertThat(headers.getAllow()).hasSameElementsAs(allowedMethods); + + if (acceptPatch != null && headers.getAllow().contains(HttpMethod.PATCH) ) { + assertThat(headers.getAcceptPatch()).containsExactly(acceptPatch); + } } private void testHttpMediaTypeNotAcceptableException(TestRequestMappingInfoHandlerMapping mapping, String url) { @@ -502,6 +514,15 @@ public HttpHeaders fooOptions() { headers.add("Allow", "PUT,POST"); return headers; } + + @RequestMapping(value = "/qux", method = RequestMethod.GET, produces = "application/xml") + public String getBaz() { + return ""; + } + + @RequestMapping(value = "/qux", method = RequestMethod.PATCH, consumes = "foo/bar") + public void patchBaz(String value) { + } } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/CrossOriginTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/CrossOriginTests.java index 25bb6c5061e9..cb9e9f2538d8 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/CrossOriginTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/CrossOriginTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -59,7 +59,7 @@ import static org.assertj.core.api.Assertions.assertThatIllegalStateException; /** - * Test fixture for {@link CrossOrigin @CrossOrigin} annotated methods. + * Tests for {@link CrossOrigin @CrossOrigin} annotated methods. * * @author Sebastien Deleuze * @author Sam Brannen @@ -362,6 +362,27 @@ void preFlightRequestWithoutRequestMethodHeader(TestRequestMappingInfoHandlerMap assertThat(mapping.getHandler(request)).isNull(); } + @PathPatternsParameterizedTest + void maxAgeWithDefaultOrigin(TestRequestMappingInfoHandlerMapping mapping) throws Exception { + mapping.registerHandler(new MaxAgeWithDefaultOriginController()); + + this.request.setRequestURI("/classAge"); + HandlerExecutionChain chain = mapping.getHandler(request); + CorsConfiguration config = getCorsConfiguration(chain, false); + assertThat(config).isNotNull(); + assertThat(config.getAllowedMethods()).containsExactly("GET"); + assertThat(config.getAllowedOrigins()).containsExactly("*"); + assertThat(config.getMaxAge()).isEqualTo(10); + + this.request.setRequestURI("/methodAge"); + chain = mapping.getHandler(request); + config = getCorsConfiguration(chain, false); + assertThat(config).isNotNull(); + assertThat(config.getAllowedMethods()).containsExactly("GET"); + assertThat(config.getAllowedOrigins()).containsExactly("*"); + assertThat(config.getMaxAge()).isEqualTo(100); + } + @Nullable private CorsConfiguration getCorsConfiguration(@Nullable HandlerExecutionChain chain, boolean isPreFlightRequest) { @@ -490,6 +511,20 @@ public void baz() { } } + @Controller + @CrossOrigin(maxAge = 10) + private static class MaxAgeWithDefaultOriginController { + + @CrossOrigin + @GetMapping("/classAge") + void classAge() { + } + + @CrossOrigin(maxAge = 100) + @GetMapping("/methodAge") + void methodAge() { + } + } @Controller @CrossOrigin(allowCredentials = "true") diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessorTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessorTests.java index 1b809cf281e5..f870aca3ca01 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessorTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessorTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -209,7 +209,6 @@ public void handleReturnValueCharSequence() throws Exception { @Test // SPR-13423 public void handleReturnValueWithETagAndETagFilter() throws Exception { - String eTagValue = "\"deadb33f8badf00d\""; String content = "body"; @@ -242,6 +241,25 @@ public void handleReturnValueWithETagAndETagFilter() throws Exception { assertThat(this.servletResponse.getContentAsString()).isEqualTo(content); } + @Test // gh-24539 + public void handleReturnValueWithMalformedAcceptHeader() throws Exception { + webRequest.getNativeRequest(MockHttpServletRequest.class).addHeader("Accept", "null"); + + List>converters = new ArrayList<>(); + converters.add(new ByteArrayHttpMessageConverter()); + converters.add(new StringHttpMessageConverter()); + + Method method = getClass().getDeclaredMethod("handle"); + MethodParameter returnType = new MethodParameter(method, -1); + ResponseEntity returnValue = ResponseEntity.badRequest().body("Foo"); + + HttpEntityMethodProcessor processor = new HttpEntityMethodProcessor(converters); + processor.handleReturnValue(returnValue, returnType, mavContainer, webRequest); + + assertThat(servletResponse.getStatus()).isEqualTo(400); + assertThat(servletResponse.getHeader("Content-Type")).isNull(); + assertThat(servletResponse.getContentAsString()).isEmpty(); + } @SuppressWarnings("unused") private void handle(HttpEntity> arg1, HttpEntity arg2) { diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandlerTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandlerTests.java index 6cc605353cc6..e5fa5c684733 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandlerTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandlerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -113,6 +113,20 @@ public void handleHttpMediaTypeNotSupported() { ResponseEntity responseEntity = testException(ex); assertThat(responseEntity.getHeaders().getAccept()).isEqualTo(acceptable); + assertThat(responseEntity.getHeaders().getAcceptPatch()).isEmpty(); + } + + @Test + public void patchHttpMediaTypeNotSupported() { + this.servletRequest = new MockHttpServletRequest("PATCH", "/"); + this.request = new ServletWebRequest(this.servletRequest, this.servletResponse); + + List acceptable = Arrays.asList(MediaType.APPLICATION_ATOM_XML, MediaType.APPLICATION_XML); + Exception ex = new HttpMediaTypeNotSupportedException(MediaType.APPLICATION_JSON, acceptable); + + ResponseEntity responseEntity = testException(ex); + assertThat(responseEntity.getHeaders().getAccept()).isEqualTo(acceptable); + assertThat(responseEntity.getHeaders().getAcceptPatch()).isEqualTo(acceptable); } @Test diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ServletAnnotationControllerHandlerMethodTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ServletAnnotationControllerHandlerMethodTests.java index e6e6ceb3ca82..eba67ab75e46 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ServletAnnotationControllerHandlerMethodTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ServletAnnotationControllerHandlerMethodTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -976,6 +976,26 @@ void unsupportedRequestBody(boolean usePathPatterns) throws Exception { assertThat(response.getHeader("Accept")).isEqualTo("text/plain"); } + @PathPatternsParameterizedTest + void unsupportedPatchBody(boolean usePathPatterns) throws Exception { + initDispatcherServlet(RequestResponseBodyController.class, usePathPatterns, wac -> { + RootBeanDefinition adapterDef = new RootBeanDefinition(RequestMappingHandlerAdapter.class); + StringHttpMessageConverter converter = new StringHttpMessageConverter(); + converter.setSupportedMediaTypes(Collections.singletonList(MediaType.TEXT_PLAIN)); + adapterDef.getPropertyValues().add("messageConverters", converter); + wac.registerBeanDefinition("handlerAdapter", adapterDef); + }); + + MockHttpServletRequest request = new MockHttpServletRequest("PATCH", "/something"); + String requestBody = "Hello World"; + request.setContent(requestBody.getBytes(StandardCharsets.UTF_8)); + request.addHeader("Content-Type", "application/pdf"); + MockHttpServletResponse response = new MockHttpServletResponse(); + getServlet().service(request, response); + assertThat(response.getStatus()).isEqualTo(415); + assertThat(response.getHeader("Accept-Patch")).isEqualTo("text/plain"); + } + @PathPatternsParameterizedTest void responseBodyNoAcceptHeader(boolean usePathPatterns) throws Exception { initDispatcherServlet(RequestResponseBodyController.class, usePathPatterns); diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/support/DefaultHandlerExceptionResolverTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/support/DefaultHandlerExceptionResolverTests.java index 2ee0ef893e77..e2574d4224c2 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/support/DefaultHandlerExceptionResolverTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/support/DefaultHandlerExceptionResolverTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -87,6 +87,18 @@ public void handleHttpMediaTypeNotSupported() { assertThat(response.getHeader("Accept")).as("Invalid Accept header").isEqualTo("application/pdf"); } + @Test + public void patchHttpMediaTypeNotSupported() { + HttpMediaTypeNotSupportedException ex = new HttpMediaTypeNotSupportedException(new MediaType("text", "plain"), + Collections.singletonList(new MediaType("application", "pdf"))); + MockHttpServletRequest request = new MockHttpServletRequest("PATCH", "/"); + ModelAndView mav = exceptionResolver.resolveException(request, response, null, ex); + assertThat(mav).as("No ModelAndView returned").isNotNull(); + assertThat(mav.isEmpty()).as("No Empty ModelAndView returned").isTrue(); + assertThat(response.getStatus()).as("Invalid status code").isEqualTo(415); + assertThat(response.getHeader("Accept-Patch")).as("Invalid Accept header").isEqualTo("application/pdf"); + } + @Test public void handleMissingPathVariable() throws NoSuchMethodException { Method method = getClass().getMethod("handle", String.class); @@ -96,7 +108,8 @@ public void handleMissingPathVariable() throws NoSuchMethodException { assertThat(mav).as("No ModelAndView returned").isNotNull(); assertThat(mav.isEmpty()).as("No Empty ModelAndView returned").isTrue(); assertThat(response.getStatus()).as("Invalid status code").isEqualTo(500); - assertThat(response.getErrorMessage()).isEqualTo("Missing URI template variable 'foo' for method parameter of type String"); + assertThat(response.getErrorMessage()) + .isEqualTo("Required URI template variable 'foo' for method parameter type String is not present"); } @Test @@ -106,7 +119,8 @@ public void handleMissingServletRequestParameter() { assertThat(mav).as("No ModelAndView returned").isNotNull(); assertThat(mav.isEmpty()).as("No Empty ModelAndView returned").isTrue(); assertThat(response.getStatus()).as("Invalid status code").isEqualTo(400); - assertThat(response.getErrorMessage()).isEqualTo("Required bar parameter 'foo' is not present"); + assertThat(response.getErrorMessage()).isEqualTo( + "Required request parameter 'foo' for method parameter type bar is not present"); } @Test diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandlerIntegrationTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandlerIntegrationTests.java new file mode 100644 index 000000000000..a55bd6f8434e --- /dev/null +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandlerIntegrationTests.java @@ -0,0 +1,179 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.web.servlet.resource; + +import java.io.IOException; +import java.net.MalformedURLException; +import java.nio.charset.StandardCharsets; +import java.util.stream.Stream; + +import javax.servlet.ServletException; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.UrlResource; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; +import org.springframework.web.servlet.DispatcherServlet; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import org.springframework.web.servlet.config.annotation.PathMatchConfigurer; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.testfixture.servlet.MockHttpServletRequest; +import org.springframework.web.testfixture.servlet.MockHttpServletResponse; +import org.springframework.web.testfixture.servlet.MockServletConfig; +import org.springframework.web.testfixture.servlet.MockServletContext; +import org.springframework.web.util.UriUtils; +import org.springframework.web.util.pattern.PathPatternParser; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +/** + * Integration tests for static resource handling. + * + * @author Rossen Stoyanchev + */ +public class ResourceHttpRequestHandlerIntegrationTests { + + private final MockServletContext servletContext = new MockServletContext(); + + private final MockServletConfig servletConfig = new MockServletConfig(this.servletContext); + + + public static Stream argumentSource() { + return Stream.of( + arguments(true, "/cp"), + arguments(true, "/fs"), + arguments(true, "/url"), + arguments(false, "/cp"), + arguments(false, "/fs"), + arguments(false, "/url") + ); + } + + + @ParameterizedTest + @MethodSource("argumentSource") + void cssFile(boolean usePathPatterns, String pathPrefix) throws Exception { + MockHttpServletRequest request = initRequest(pathPrefix + "/test/foo.css"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + DispatcherServlet servlet = initDispatcherServlet(usePathPatterns, WebConfig.class); + servlet.service(request, response); + + String description = "usePathPattern=" + usePathPatterns + ", prefix=" + pathPrefix; + assertThat(response.getStatus()).as(description).isEqualTo(200); + assertThat(response.getContentType()).as(description).isEqualTo("text/css"); + assertThat(response.getContentAsString()).as(description).isEqualTo("h1 { color:red; }"); + } + + @ParameterizedTest + @MethodSource("argumentSource") + void classpathLocationWithEncodedPath(boolean usePathPatterns, String pathPrefix) throws Exception { + MockHttpServletRequest request = initRequest(pathPrefix + "/test/foo with spaces.css"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + DispatcherServlet servlet = initDispatcherServlet(usePathPatterns, WebConfig.class); + servlet.service(request, response); + + String description = "usePathPattern=" + usePathPatterns + ", prefix=" + pathPrefix; + assertThat(response.getStatus()).as(description).isEqualTo(200); + assertThat(response.getContentType()).as(description).isEqualTo("text/css"); + assertThat(response.getContentAsString()).as(description).isEqualTo("h1 { color:red; }"); + } + + private DispatcherServlet initDispatcherServlet(boolean usePathPatterns, Class... configClasses) + throws ServletException { + + AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext(); + context.register(configClasses); + if (usePathPatterns) { + context.register(PathPatternParserConfig.class); + } + context.setServletConfig(this.servletConfig); + context.refresh(); + + DispatcherServlet servlet = new DispatcherServlet(); + servlet.setApplicationContext(context); + servlet.init(this.servletConfig); + return servlet; + } + + private MockHttpServletRequest initRequest(String path) { + path = UriUtils.encodePath(path, StandardCharsets.UTF_8); + MockHttpServletRequest request = new MockHttpServletRequest("GET", path); + request.setCharacterEncoding(StandardCharsets.UTF_8.name()); + return request; + } + + + @EnableWebMvc + static class WebConfig implements WebMvcConfigurer { + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + ClassPathResource classPathLocation = new ClassPathResource("", getClass()); + String path = getPath(classPathLocation); + + registerClasspathLocation("/cp/**", classPathLocation, registry); + registerFileSystemLocation("/fs/**", path, registry); + registerUrlLocation("/url/**", "file:" + path, registry); + } + + protected void registerClasspathLocation(String pattern, ClassPathResource resource, ResourceHandlerRegistry registry) { + registry.addResourceHandler(pattern).addResourceLocations(resource); + } + + protected void registerFileSystemLocation(String pattern, String path, ResourceHandlerRegistry registry) { + FileSystemResource fileSystemLocation = new FileSystemResource(path); + registry.addResourceHandler(pattern).addResourceLocations(fileSystemLocation); + } + + protected void registerUrlLocation(String pattern, String path, ResourceHandlerRegistry registry) { + try { + UrlResource urlLocation = new UrlResource(path); + registry.addResourceHandler(pattern).addResourceLocations(urlLocation); + } + catch (MalformedURLException ex) { + throw new IllegalStateException(ex); + } + } + + private String getPath(ClassPathResource resource) { + try { + return resource.getFile().getCanonicalPath().replace('\\', '/').replace("classes/java", "resources") + "/"; + } + catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + } + + + static class PathPatternParserConfig implements WebMvcConfigurer { + + @Override + public void configurePathMatch(PathMatchConfigurer configurer) { + configurer.setPatternParser(new PathPatternParser()); + } + } + +} diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/ResourceUrlProviderTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/ResourceUrlProviderTests.java index c808f79858b2..b3c719ad7320 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/ResourceUrlProviderTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/ResourceUrlProviderTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -44,6 +44,7 @@ * * @author Jeremy Grelle * @author Rossen Stoyanchev + * @author Brian Clozel */ public class ResourceUrlProviderTests { @@ -57,7 +58,7 @@ public class ResourceUrlProviderTests { @BeforeEach - public void setUp() throws Exception { + void setUp() throws Exception { this.locations.add(new ClassPathResource("test/", getClass())); this.locations.add(new ClassPathResource("testalternatepath/", getClass())); this.handler.setServletContext(new MockServletContext()); @@ -69,13 +70,13 @@ public void setUp() throws Exception { @Test - public void getStaticResourceUrl() { + void getStaticResourceUrl() { String url = this.urlProvider.getForLookupPath("/resources/foo.css"); assertThat(url).isEqualTo("/resources/foo.css"); } @Test // SPR-13374 - public void getStaticResourceUrlRequestWithQueryOrHash() { + void getStaticResourceUrlRequestWithQueryOrHash() { MockHttpServletRequest request = new MockHttpServletRequest(); request.setContextPath("/"); request.setRequestURI("/"); @@ -90,7 +91,7 @@ public void getStaticResourceUrlRequestWithQueryOrHash() { } @Test // SPR-16526 - public void getStaticResourceWithMissingContextPath() { + void getStaticResourceWithMissingContextPath() { MockHttpServletRequest request = new MockHttpServletRequest(); request.setContextPath("/contextpath-longer-than-request-path"); request.setRequestURI("/contextpath-longer-than-request-path/style.css"); @@ -100,7 +101,7 @@ public void getStaticResourceWithMissingContextPath() { } @Test - public void getFingerprintedResourceUrl() { + void getFingerprintedResourceUrl() { Map versionStrategyMap = new HashMap<>(); versionStrategyMap.put("/**", new ContentVersionStrategy()); VersionResourceResolver versionResolver = new VersionResourceResolver(); @@ -116,7 +117,7 @@ public void getFingerprintedResourceUrl() { } @Test // SPR-12647 - public void bestPatternMatch() throws Exception { + void bestPatternMatch() throws Exception { ResourceHttpRequestHandler otherHandler = new ResourceHttpRequestHandler(); otherHandler.setLocations(this.locations); Map versionStrategyMap = new HashMap<>(); @@ -138,7 +139,7 @@ public void bestPatternMatch() throws Exception { @Test // SPR-12592 @SuppressWarnings("resource") - public void initializeOnce() throws Exception { + void initializeOnce() throws Exception { AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext(); context.setServletContext(new MockServletContext()); context.register(HandlerMappingConfiguration.class); @@ -149,8 +150,30 @@ public void initializeOnce() throws Exception { assertThat(urlProviderBean.isAutodetect()).isFalse(); } + @Test + void initializeOnCurrentContext() { + AnnotationConfigWebApplicationContext parentContext = new AnnotationConfigWebApplicationContext(); + parentContext.setServletContext(new MockServletContext()); + parentContext.register(ParentHandlerMappingConfiguration.class); + + AnnotationConfigWebApplicationContext childContext = new AnnotationConfigWebApplicationContext(); + childContext.setParent(parentContext); + childContext.setServletContext(new MockServletContext()); + childContext.register(HandlerMappingConfiguration.class); + + parentContext.refresh(); + childContext.refresh(); + + ResourceUrlProvider parentUrlProvider = parentContext.getBean(ResourceUrlProvider.class); + assertThat(parentUrlProvider.getHandlerMap()).isEmpty(); + assertThat(parentUrlProvider.isAutodetect()).isTrue(); + ResourceUrlProvider childUrlProvider = childContext.getBean(ResourceUrlProvider.class); + assertThat(childUrlProvider.getHandlerMap()).containsOnlyKeys("/resources/**"); + assertThat(childUrlProvider.isAutodetect()).isFalse(); + } + @Test // SPR-16296 - public void getForLookupPathShouldNotFailIfPathContainsDoubleSlashes() { + void getForLookupPathShouldNotFailIfPathContainsDoubleSlashes() { // given ResourceResolver mockResourceResolver = mock(ResourceResolver.class); given(mockResourceResolver.resolveUrlPath(any(), any(), any())).willReturn("some-path"); @@ -185,4 +208,14 @@ public ResourceUrlProvider resourceUrlProvider() { } } + @Configuration + @SuppressWarnings({"unused", "WeakerAccess"}) + static class ParentHandlerMappingConfiguration { + + @Bean + public ResourceUrlProvider resourceUrlProvider() { + return new ResourceUrlProvider(); + } + } + } diff --git a/spring-webmvc/src/test/resources/org/springframework/web/servlet/resource/test/foo with spaces.css b/spring-webmvc/src/test/resources/org/springframework/web/servlet/resource/test/foo with spaces.css new file mode 100644 index 000000000000..e2f0b1c742ae --- /dev/null +++ b/spring-webmvc/src/test/resources/org/springframework/web/servlet/resource/test/foo with spaces.css @@ -0,0 +1 @@ +h1 { color:red; } \ No newline at end of file diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/config/annotation/AbstractWebSocketHandlerRegistration.java b/spring-websocket/src/main/java/org/springframework/web/socket/config/annotation/AbstractWebSocketHandlerRegistration.java index ca30c23c77d0..b43b3291ad93 100644 --- a/spring-websocket/src/main/java/org/springframework/web/socket/config/annotation/AbstractWebSocketHandlerRegistration.java +++ b/spring-websocket/src/main/java/org/springframework/web/socket/config/annotation/AbstractWebSocketHandlerRegistration.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -54,6 +54,8 @@ public abstract class AbstractWebSocketHandlerRegistration implements WebSock private final List allowedOrigins = new ArrayList<>(); + private final List allowedOriginPatterns = new ArrayList<>(); + @Nullable private SockJsServiceRegistration sockJsServiceRegistration; @@ -94,6 +96,15 @@ public WebSocketHandlerRegistration setAllowedOrigins(String... allowedOrigins) return this; } + @Override + public WebSocketHandlerRegistration setAllowedOriginPatterns(String... allowedOriginPatterns) { + this.allowedOriginPatterns.clear(); + if (!ObjectUtils.isEmpty(allowedOriginPatterns)) { + this.allowedOriginPatterns.addAll(Arrays.asList(allowedOriginPatterns)); + } + return this; + } + @Override public SockJsServiceRegistration withSockJS() { this.sockJsServiceRegistration = new SockJsServiceRegistration(); @@ -108,13 +119,21 @@ public SockJsServiceRegistration withSockJS() { if (!this.allowedOrigins.isEmpty()) { this.sockJsServiceRegistration.setAllowedOrigins(StringUtils.toStringArray(this.allowedOrigins)); } + if (!this.allowedOriginPatterns.isEmpty()) { + this.sockJsServiceRegistration.setAllowedOriginPatterns( + StringUtils.toStringArray(this.allowedOriginPatterns)); + } return this.sockJsServiceRegistration; } protected HandshakeInterceptor[] getInterceptors() { List interceptors = new ArrayList<>(this.interceptors.size() + 1); interceptors.addAll(this.interceptors); - interceptors.add(new OriginHandshakeInterceptor(this.allowedOrigins)); + OriginHandshakeInterceptor interceptor = new OriginHandshakeInterceptor(this.allowedOrigins); + if (!ObjectUtils.isEmpty(this.allowedOriginPatterns)) { + interceptor.setAllowedOriginPatterns(this.allowedOriginPatterns); + } + interceptors.add(interceptor); return interceptors.toArray(new HandshakeInterceptor[0]); } diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/config/annotation/WebSocketHandlerRegistration.java b/spring-websocket/src/main/java/org/springframework/web/socket/config/annotation/WebSocketHandlerRegistration.java index 72b484a98924..48642a305bdf 100644 --- a/spring-websocket/src/main/java/org/springframework/web/socket/config/annotation/WebSocketHandlerRegistration.java +++ b/spring-websocket/src/main/java/org/springframework/web/socket/config/annotation/WebSocketHandlerRegistration.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -63,6 +63,15 @@ public interface WebSocketHandlerRegistration { */ WebSocketHandlerRegistration setAllowedOrigins(String... origins); + /** + * A variant of {@link #setAllowedOrigins(String...)} that accepts flexible + * domain patterns, e.g. {@code "https://*.domain1.com"}. Furthermore it + * always sets the {@code Access-Control-Allow-Origin} response header to + * the matched origin and never to {@code "*"}, nor to any other pattern. + * @since 5.3.5 + */ + WebSocketHandlerRegistration setAllowedOriginPatterns(String... originPatterns); + /** * Enable SockJS fallback options. */ diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/server/support/WebSocketHandlerMapping.java b/spring-websocket/src/main/java/org/springframework/web/socket/server/support/WebSocketHandlerMapping.java index 6d46b2d93646..1ce49177a542 100644 --- a/spring-websocket/src/main/java/org/springframework/web/socket/server/support/WebSocketHandlerMapping.java +++ b/spring-websocket/src/main/java/org/springframework/web/socket/server/support/WebSocketHandlerMapping.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,26 +17,47 @@ package org.springframework.web.socket.server.support; import javax.servlet.ServletContext; +import javax.servlet.http.HttpServletRequest; import org.springframework.context.Lifecycle; import org.springframework.context.SmartLifecycle; +import org.springframework.http.HttpHeaders; +import org.springframework.lang.Nullable; import org.springframework.web.context.ServletContextAware; +import org.springframework.web.servlet.HandlerExecutionChain; import org.springframework.web.servlet.handler.SimpleUrlHandlerMapping; /** - * An extension of {@link SimpleUrlHandlerMapping} that is also a - * {@link SmartLifecycle} container and propagates start and stop calls to any - * handlers that implement {@link Lifecycle}. The handlers are typically expected - * to be {@code WebSocketHttpRequestHandler} or {@code SockJsHttpRequestHandler}. + * Extension of {@link SimpleUrlHandlerMapping} with support for more + * precise mapping of WebSocket handshake requests to handlers of type + * {@link WebSocketHttpRequestHandler}. Also delegates {@link Lifecycle} + * methods to handlers in the {@link #getUrlMap()} that implement it. * * @author Rossen Stoyanchev * @since 4.2 */ public class WebSocketHandlerMapping extends SimpleUrlHandlerMapping implements SmartLifecycle { + private boolean webSocketUpgradeMatch; + private volatile boolean running; + /** + * When this is set, if the matched handler is + * {@link WebSocketHttpRequestHandler}, ensure the request is a WebSocket + * handshake, i.e. HTTP GET with the header {@code "Upgrade:websocket"}, + * or otherwise suppress the match and return {@code null} allowing another + * {@link org.springframework.web.servlet.HandlerMapping} to match for the + * same URL path. + * @param match whether to enable matching on {@code "Upgrade: websocket"} + * @since 5.3.5 + */ + public void setWebSocketUpgradeMatch(boolean match) { + this.webSocketUpgradeMatch = match; + } + + @Override protected void initServletContext(ServletContext servletContext) { for (Object handler : getUrlMap().values()) { @@ -76,4 +97,22 @@ public boolean isRunning() { return this.running; } + @Nullable + @Override + protected Object getHandlerInternal(HttpServletRequest request) throws Exception { + Object handler = super.getHandlerInternal(request); + return matchWebSocketUpgrade(handler, request) ? handler : null; + } + + private boolean matchWebSocketUpgrade(@Nullable Object handler, HttpServletRequest request) { + handler = (handler instanceof HandlerExecutionChain ? + ((HandlerExecutionChain) handler).getHandler() : handler); + if (this.webSocketUpgradeMatch && handler instanceof WebSocketHttpRequestHandler) { + String header = request.getHeader(HttpHeaders.UPGRADE); + return (request.getMethod().equals("GET") && + header != null && header.equalsIgnoreCase("websocket")); + } + return true; + } + } diff --git a/spring-websocket/src/test/java/org/springframework/web/socket/config/annotation/WebSocketHandlerRegistrationTests.java b/spring-websocket/src/test/java/org/springframework/web/socket/config/annotation/WebSocketHandlerRegistrationTests.java index 398c7afe134e..f7dae4c8cb02 100644 --- a/spring-websocket/src/test/java/org/springframework/web/socket/config/annotation/WebSocketHandlerRegistrationTests.java +++ b/spring-websocket/src/test/java/org/springframework/web/socket/config/annotation/WebSocketHandlerRegistrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -115,7 +115,10 @@ public void interceptorsWithAllowedOrigins() { WebSocketHandler handler = new TextWebSocketHandler(); HttpSessionHandshakeInterceptor interceptor = new HttpSessionHandshakeInterceptor(); - this.registration.addHandler(handler, "/foo").addInterceptors(interceptor).setAllowedOrigins("https://mydomain1.example"); + this.registration.addHandler(handler, "/foo") + .addInterceptors(interceptor) + .setAllowedOrigins("https://mydomain1.example") + .setAllowedOriginPatterns("https://*.abc.com"); List mappings = this.registration.getMappings(); assertThat(mappings.size()).isEqualTo(1); @@ -126,7 +129,10 @@ public void interceptorsWithAllowedOrigins() { assertThat(mapping.interceptors).isNotNull(); assertThat(mapping.interceptors.length).isEqualTo(2); assertThat(mapping.interceptors[0]).isEqualTo(interceptor); - assertThat(mapping.interceptors[1].getClass()).isEqualTo(OriginHandshakeInterceptor.class); + + OriginHandshakeInterceptor originInterceptor = (OriginHandshakeInterceptor) mapping.interceptors[1]; + assertThat(originInterceptor.getAllowedOrigins()).containsExactly("https://mydomain1.example"); + assertThat(originInterceptor.getAllowedOriginPatterns()).containsExactly("https://*.abc.com"); } @Test @@ -137,6 +143,7 @@ public void interceptorsPassedToSockJsRegistration() { this.registration.addHandler(handler, "/foo") .addInterceptors(interceptor) .setAllowedOrigins("https://mydomain1.example") + .setAllowedOriginPatterns("https://*.abc.com") .withSockJS(); this.registration.getSockJsServiceRegistration().setTaskScheduler(this.taskScheduler); @@ -151,7 +158,10 @@ public void interceptorsPassedToSockJsRegistration() { assertThat(mapping.sockJsService.getAllowedOrigins().contains("https://mydomain1.example")).isTrue(); List interceptors = mapping.sockJsService.getHandshakeInterceptors(); assertThat(interceptors.get(0)).isEqualTo(interceptor); - assertThat(interceptors.get(1).getClass()).isEqualTo(OriginHandshakeInterceptor.class); + + OriginHandshakeInterceptor originInterceptor = (OriginHandshakeInterceptor) interceptors.get(1); + assertThat(originInterceptor.getAllowedOrigins()).containsExactly("https://mydomain1.example"); + assertThat(originInterceptor.getAllowedOriginPatterns()).containsExactly("https://*.abc.com"); } @Test diff --git a/spring-websocket/src/test/java/org/springframework/web/socket/server/support/WebSocketHandlerMappingTests.java b/spring-websocket/src/test/java/org/springframework/web/socket/server/support/WebSocketHandlerMappingTests.java new file mode 100644 index 000000000000..b5ec3cdfdbd9 --- /dev/null +++ b/spring-websocket/src/test/java/org/springframework/web/socket/server/support/WebSocketHandlerMappingTests.java @@ -0,0 +1,70 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.web.socket.server.support; + +import java.util.Collections; + +import org.junit.jupiter.api.Test; + +import org.springframework.web.HttpRequestHandler; +import org.springframework.web.context.support.StaticWebApplicationContext; +import org.springframework.web.servlet.HandlerExecutionChain; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.testfixture.servlet.MockHttpServletRequest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Unit tests for {@link WebSocketHandlerMapping}. + * + * @author Rossen Stoyanchev + */ +public class WebSocketHandlerMappingTests { + + + @Test + void webSocketHandshakeMatch() throws Exception { + HttpRequestHandler handler = new WebSocketHttpRequestHandler(mock(WebSocketHandler.class)); + + WebSocketHandlerMapping mapping = new WebSocketHandlerMapping(); + mapping.setUrlMap(Collections.singletonMap("/path", handler)); + mapping.setApplicationContext(new StaticWebApplicationContext()); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/path"); + + HandlerExecutionChain chain = mapping.getHandler(request); + assertThat(chain).isNotNull(); + assertThat(chain.getHandler()).isSameAs(handler); + + mapping.setWebSocketUpgradeMatch(true); + + chain = mapping.getHandler(request); + assertThat(chain).isNull(); + + request.addHeader("Upgrade", "websocket"); + + chain = mapping.getHandler(request); + assertThat(chain).isNotNull(); + assertThat(chain.getHandler()).isSameAs(handler); + + request.setMethod("POST"); + + chain = mapping.getHandler(request); + assertThat(chain).isNull(); + } + +} diff --git a/src/docs/asciidoc/appendix.adoc b/src/docs/asciidoc/appendix.adoc new file mode 100644 index 000000000000..4de25e064513 --- /dev/null +++ b/src/docs/asciidoc/appendix.adoc @@ -0,0 +1,84 @@ +[[appendix]] += Appendix +:doc-root: https://docs.spring.io +:api-spring-framework: {doc-root}/spring-framework/docs/{spring-version}/javadoc-api/org/springframework +:toc: left +:toclevels: 4 +:tabsize: 4 +:docinfo1: + +This part of the reference documentation covers topics that apply to multiple modules +within the core Spring Framework. + + +[[appendix-spring-properties]] +== Spring Properties + +{api-spring-framework}/core/SpringProperties.html[`SpringProperties`] is a static holder +for properties that control certain low-level aspects of the Spring Framework. Users can +configure these properties via JVM system properties or programmatically via the +`SpringProperties.setProperty(String key, String value)` method. The latter may be +necessary if the deployment environment disallows custom JVM system properties. As an +alternative, these properties may be configured in a `spring.properties` file in the root +of the classpath -- for example, deployed within the application's JAR file. + +The following table lists all currently supported Spring properties. + +.Supported Spring Properties +|=== +| Name | Description + +| `spring.beaninfo.ignore` +| Instructs Spring to use the `Introspector.IGNORE_ALL_BEANINFO` mode when calling the +JavaBeans `Introspector`. See +{api-spring-framework}++/beans/CachedIntrospectionResults.html#IGNORE_BEANINFO_PROPERTY_NAME++[`CachedIntrospectionResults`] +for details. + +| `spring.expression.compiler.mode` +| The mode to use when compiling expressions for the +<>. + +| `spring.getenv.ignore` +| Instructs Spring to ignore operating system environment variables if a Spring +`Environment` property -- for example, a placeholder in a configuration String -- isn't +resolvable otherwise. See +{api-spring-framework}++/core/env/AbstractEnvironment.html#IGNORE_GETENV_PROPERTY_NAME++[`AbstractEnvironment`] +for details. + +| `spring.index.ignore` +| Instructs Spring to ignore the components index located in +`META-INF/spring.components`. See <>. + +| `spring.jdbc.getParameterType.ignore` +| Instructs Spring to ignore `java.sql.ParameterMetaData.getParameterType` completely. +See the note in <>. + +| `spring.jndi.ignore` +| Instructs Spring to ignore a default JNDI environment, as an optimization for scenarios +where nothing is ever to be found for such JNDI fallback searches to begin with, avoiding +the repeated JNDI lookup overhead. See +{api-spring-framework}++/jndi/JndiLocatorDelegate.html#IGNORE_JNDI_PROPERTY_NAME++[`JndiLocatorDelegate`] +for details. + +| `spring.objenesis.ignore` +| Instructs Spring to ignore Objenesis, not even attempting to use it. See +{api-spring-framework}++/objenesis/SpringObjenesis.html#IGNORE_OBJENESIS_PROPERTY_NAME++[`SpringObjenesis`] +for details. + +| `spring.test.constructor.autowire.mode` +| The default _test constructor autowire mode_ to use if `@TestConstructor` is not present +on a test class. See <>. + +| `spring.test.context.cache.maxSize` +| The maximum size of the context cache in the _Spring TestContext Framework_. See +<>. + +| `spring.test.enclosing.configuration` +| The default _enclosing configuration inheritance mode_ to use if +`@NestedTestConfiguration` is not present on a test class. See +<>. + +|=== diff --git a/src/docs/asciidoc/core/core-appendix.adoc b/src/docs/asciidoc/core/core-appendix.adoc index 60e8c81d9931..cc5fd0b3d86e 100644 --- a/src/docs/asciidoc/core/core-appendix.adoc +++ b/src/docs/asciidoc/core/core-appendix.adoc @@ -568,8 +568,9 @@ is a convenience mechanism that sets up a <> model -* <> and `@Value` -* JSR-250's `@Resource`, `@PostConstruct` and `@PreDestroy` (if available) +* <>, `@Value`, and `@Lookup` +* JSR-250's `@Resource`, `@PostConstruct`, and `@PreDestroy` (if available) +* JAX-WS's `@WebServiceRef` and EJB 3's `@EJB` (if available) * JPA's `@PersistenceContext` and `@PersistenceUnit` (if available) * Spring's <> diff --git a/src/docs/asciidoc/core/core-beans.adoc b/src/docs/asciidoc/core/core-beans.adoc index 258082f2f0ee..9d0d31359255 100644 --- a/src/docs/asciidoc/core/core-beans.adoc +++ b/src/docs/asciidoc/core/core-beans.adoc @@ -923,7 +923,7 @@ injection: public class SimpleMovieLister { // the SimpleMovieLister has a dependency on a MovieFinder - private MovieFinder movieFinder; + private final MovieFinder movieFinder; // a constructor so that the Spring container can inject a MovieFinder public SimpleMovieLister(MovieFinder movieFinder) { @@ -943,7 +943,7 @@ injection: ---- Notice that there is nothing special about this class. It is a POJO that -has no dependencies on container specific interfaces, base classes or annotations. +has no dependencies on container specific interfaces, base classes, or annotations. [[beans-factory-ctor-arguments-resolution]] ===== Constructor Argument Resolution @@ -974,10 +974,10 @@ being instantiated. Consider the following class: class ThingOne(thingTwo: ThingTwo, thingThree: ThingThree) ---- -Assuming that `ThingTwo` and `ThingThree` classes are not related by inheritance, no potential -ambiguity exists. Thus, the following configuration works fine, and you do not need to specify -the constructor argument indexes or types explicitly in the `` -element. +Assuming that the `ThingTwo` and `ThingThree` classes are not related by inheritance, no +potential ambiguity exists. Thus, the following configuration works fine, and you do not +need to specify the constructor argument indexes or types explicitly in the +`` element. [source,xml,indent=0,subs="verbatim,quotes"] ---- @@ -1006,10 +1006,10 @@ by type without help. Consider the following class: public class ExampleBean { // Number of years to calculate the Ultimate Answer - private int years; + private final int years; // The Answer to Life, the Universe, and Everything - private String ultimateAnswer; + private final String ultimateAnswer; public ExampleBean(int years, String ultimateAnswer) { this.years = years; @@ -1031,7 +1031,7 @@ by type without help. Consider the following class: .[[beans-factory-ctor-arguments-type]]Constructor argument type matching -- In the preceding scenario, the container can use type matching with simple types if -you explicitly specify the type of the constructor argument by using the `type` attribute. +you explicitly specify the type of the constructor argument by using the `type` attribute, as the following example shows: [source,xml,indent=0,subs="verbatim,quotes"] @@ -1251,7 +1251,8 @@ visibility of some configuration issues is why `ApplicationContext` implementati default pre-instantiate singleton beans. At the cost of some upfront time and memory to create these beans before they are actually needed, you discover configuration issues when the `ApplicationContext` is created, not later. You can still override this default -behavior so that singleton beans initialize lazily, rather than being pre-instantiated. +behavior so that singleton beans initialize lazily, rather than being eagerly +pre-instantiated. If no circular dependencies exist, when one or more collaborating beans are being injected into a dependent bean, each collaborating bean is totally configured prior @@ -4316,15 +4317,14 @@ org.springframework.scripting.groovy.GroovyMessenger@272961 ---- -[[beans-factory-extension-bpp-examples-rabpp]] -==== Example: The `RequiredAnnotationBeanPostProcessor` +[[beans-factory-extension-bpp-examples-aabpp]] +==== Example: The `AutowiredAnnotationBeanPostProcessor` -Using callback interfaces or annotations in conjunction with a custom -`BeanPostProcessor` implementation is a common means of extending the Spring IoC -container. An example is Spring's `RequiredAnnotationBeanPostProcessor` -- a -`BeanPostProcessor` implementation that ships with the Spring distribution and that ensures -that JavaBean properties on beans that are marked with an (arbitrary) annotation are -actually (configured to be) dependency-injected with a value. +Using callback interfaces or annotations in conjunction with a custom `BeanPostProcessor` +implementation is a common means of extending the Spring IoC container. An example is +Spring's `AutowiredAnnotationBeanPostProcessor` -- a `BeanPostProcessor` implementation +that ships with the Spring distribution and autowires annotated fields, setter methods, +and arbitrary config methods. @@ -4588,7 +4588,7 @@ An alternative to XML setup is provided by annotation-based configuration, which the bytecode metadata for wiring up components instead of angle-bracket declarations. Instead of using XML to describe a bean wiring, the developer moves the configuration into the component class itself by using annotations on the relevant class, method, or -field declaration. As mentioned in <>, using +field declaration. As mentioned in <>, using a `BeanPostProcessor` in conjunction with annotations is a common means of extending the Spring IoC container. For example, Spring 2.0 introduced the possibility of enforcing required properties with the <> annotation. Spring @@ -4607,8 +4607,8 @@ Annotation injection is performed before XML injection. Thus, the XML configurat overrides the annotations for properties wired through both approaches. ==== -As always, you can register them as individual bean definitions, but they can also be -implicitly registered by including the following tag in an XML-based Spring +As always, you can register the post-processors as individual bean definitions, but they +can also be implicitly registered by including the following tag in an XML-based Spring configuration (notice the inclusion of the `context` namespace): [source,xml,indent=0,subs="verbatim,quotes"] @@ -4627,12 +4627,13 @@ configuration (notice the inclusion of the `context` namespace): ---- -(The implicitly registered post-processors include -{api-spring-framework}/beans/factory/annotation/AutowiredAnnotationBeanPostProcessor.html[`AutowiredAnnotationBeanPostProcessor`], -{api-spring-framework}/context/annotation/CommonAnnotationBeanPostProcessor.html[`CommonAnnotationBeanPostProcessor`], -{api-spring-framework}/orm/jpa/support/PersistenceAnnotationBeanPostProcessor.html[`PersistenceAnnotationBeanPostProcessor`], -and the aforementioned -{api-spring-framework}/beans/factory/annotation/RequiredAnnotationBeanPostProcessor.html[`RequiredAnnotationBeanPostProcessor`].) +The `` element implicitly registers the following post-processors: + +* {api-spring-framework}/context/annotation/ConfigurationClassPostProcessor.html[`ConfigurationClassPostProcessor`] +* {api-spring-framework}/beans/factory/annotation/AutowiredAnnotationBeanPostProcessor.html[`AutowiredAnnotationBeanPostProcessor`] +* {api-spring-framework}/context/annotation/CommonAnnotationBeanPostProcessor.html[`CommonAnnotationBeanPostProcessor`] +* {api-spring-framework}/orm/jpa/support/PersistenceAnnotationBeanPostProcessor.html[`PersistenceAnnotationBeanPostProcessor`] +* {api-spring-framework}/context/event/EventListenerMethodProcessor.html[`EventListenerMethodProcessor`] [NOTE] ==== @@ -4678,7 +4679,6 @@ example: } ---- - This annotation indicates that the affected bean property must be populated at configuration time, through an explicit property value in a bean definition or through autowiring. The container throws an exception if the affected bean property has not been @@ -4687,11 +4687,18 @@ instances or the like later on. We still recommend that you put assertions into bean class itself (for example, into an init method). Doing so enforces those required references and values even when you use the class outside of a container. +[TIP] +==== +The {api-spring-framework}/beans/factory/annotation/RequiredAnnotationBeanPostProcessor.html[`RequiredAnnotationBeanPostProcessor`] +must be registered as a bean to enable support for the `@Required` annotation. +==== + [NOTE] ==== -The `@Required` annotation is formally deprecated as of Spring Framework 5.1, in favor -of using constructor injection for required settings (or a custom implementation of -`InitializingBean.afterPropertiesSet()` along with bean property setter methods). +The `@Required` annotation and `RequiredAnnotationBeanPostProcessor` are formally +deprecated as of Spring Framework 5.1, in favor of using constructor injection for +required settings (or a custom implementation of `InitializingBean.afterPropertiesSet()` +or a custom `@PostConstruct` method along with bean property setter methods). ==== @@ -7113,10 +7120,10 @@ metadata is provided per-instance rather than per-class. While classpath scanning is very fast, it is possible to improve the startup performance of large applications by creating a static list of candidates at compilation time. In this -mode, all modules that are target of component scan must use this mechanism. +mode, all modules that are targets of component scanning must use this mechanism. -NOTE: Your existing `@ComponentScan` or `` directives must remain +unchanged to request the context to scan candidates in certain packages. When the `ApplicationContext` detects such an index, it automatically uses it rather than scanning the classpath. @@ -7145,12 +7152,10 @@ configuration, as shown in the following example: compileOnly "org.springframework:spring-context-indexer:{spring-version}" } ---- -==== With Gradle 4.6 and later, the dependency should be declared in the `annotationProcessor` configuration, as shown in the following example: -==== [source,groovy,indent=0subs="verbatim,quotes,attributes"] ---- dependencies { @@ -7158,19 +7163,20 @@ configuration, as shown in the following example: } ---- -That process generates a `META-INF/spring.components` file that is -included in the jar file. +The `spring-context-indexer` artifact generates a `META-INF/spring.components` file that +is included in the jar file. NOTE: When working with this mode in your IDE, the `spring-context-indexer` must be registered as an annotation processor to make sure the index is up-to-date when candidate components are updated. -TIP: The index is enabled automatically when a `META-INF/spring.components` is found +TIP: The index is enabled automatically when a `META-INF/spring.components` file is found on the classpath. If an index is partially available for some libraries (or use cases) -but could not be built for the whole application, you can fallback to a regular classpath -arrangement (as though no index was present at all) by setting `spring.index.ignore` to -`true`, either as a system property or in a `spring.properties` file at the root of the -classpath. +but could not be built for the whole application, you can fall back to a regular classpath +arrangement (as though no index were present at all) by setting `spring.index.ignore` to +`true`, either as a JVM system property or via the +<> mechanism. + @@ -10646,9 +10652,8 @@ architectures that build upon the well-known Spring programming model. [[context-functionality-events-annotation]] ==== Annotation-based Event Listeners -As of Spring 4.2, you can register an event listener on any public method of a managed -bean by using the `@EventListener` annotation. The `BlockedListNotifier` can be rewritten as -follows: +You can register an event listener on any method of a managed bean by using the +`@EventListener` annotation. The `BlockedListNotifier` can be rewritten as follows: [source,java,indent=0,subs="verbatim,quotes",role="primary"] .Java @@ -10718,7 +10723,7 @@ The following example shows how our notifier can be rewritten to be invoked only .Java ---- @EventListener(condition = "#blEvent.content == 'my-event'") - public void processBlockedListEvent(BlockedListEvent blockedListEvent) { + public void processBlockedListEvent(BlockedListEvent blEvent) { // notify appropriate parties via notificationAddress... } ---- @@ -10726,7 +10731,7 @@ The following example shows how our notifier can be rewritten to be invoked only .Kotlin ---- @EventListener(condition = "#blEvent.content == 'my-event'") - fun processBlockedListEvent(blockedListEvent: BlockedListEvent) { + fun processBlockedListEvent(blEvent: BlockedListEvent) { // notify appropriate parties via notificationAddress... } ---- @@ -10786,9 +10791,9 @@ method signature to return the event that should be published, as the following NOTE: This feature is not supported for <>. -This new method publishes a new `ListUpdateEvent` for every `BlockedListEvent` handled by the -method above. If you need to publish several events, you can return a `Collection` of events -instead. +The `handleBlockedListEvent()` method publishes a new `ListUpdateEvent` for every +`BlockedListEvent` that it handles. If you need to publish several events, you can return +a `Collection` or an array of events instead. [[context-functionality-events-async]] @@ -10948,8 +10953,10 @@ location path as a classpath location. You can also use location paths (resource with special prefixes to force loading of definitions from the classpath or a URL, regardless of the actual context type. + + [[context-functionality-startup]] -=== Application Startup tracking +=== Application Startup Tracking The `ApplicationContext` manages the lifecycle of Spring applications and provides a rich programming model around components. As a result, complex applications can have equally diff --git a/src/docs/asciidoc/core/core-expressions.adoc b/src/docs/asciidoc/core/core-expressions.adoc index 5a1399dd831b..d445738f5130 100644 --- a/src/docs/asciidoc/core/core-expressions.adoc +++ b/src/docs/asciidoc/core/core-expressions.adoc @@ -433,9 +433,9 @@ interpreter and only 3ms using the compiled version of the expression. The compiler is not turned on by default, but you can turn it on in either of two different ways. You can turn it on by using the parser configuration process -(<>) or by using a system -property when SpEL usage is embedded inside another component. This section -discusses both of these options. +(<>) or by using a Spring property +when SpEL usage is embedded inside another component. This section discusses both of +these options. The compiler can operate in one of three modes, which are captured in the `org.springframework.expression.spel.SpelCompilerMode` enum. The modes are as follows: @@ -496,10 +496,12 @@ It is important to ensure that, if a classloader is specified, it can see all th the expression evaluation process. If you do not specify a classloader, a default classloader is used (typically the context classloader for the thread that is running during expression evaluation). -The second way to configure the compiler is for use when SpEL is embedded inside some other -component and it may not be possible to configure it through a configuration object. In these -cases, it is possible to use a system property. You can set the `spring.expression.compiler.mode` -property to one of the `SpelCompilerMode` enum values (`off`, `immediate`, or `mixed`). +The second way to configure the compiler is for use when SpEL is embedded inside some +other component and it may not be possible to configure it through a configuration +object. In these cases, it is possible to set the `spring.expression.compiler.mode` +property via a JVM system property (or via the +<> mechanism) to one of the +`SpelCompilerMode` enum values (`off`, `immediate`, or `mixed`). [[expressions-compiler-limitations]] diff --git a/src/docs/asciidoc/core/core-resources.adoc b/src/docs/asciidoc/core/core-resources.adoc index 812b8163b65c..4aaa4575c875 100644 --- a/src/docs/asciidoc/core/core-resources.adoc +++ b/src/docs/asciidoc/core/core-resources.adoc @@ -465,33 +465,58 @@ application components instead of `ResourceLoader`. == Resources as Dependencies If the bean itself is going to determine and supply the resource path through some sort -of dynamic process, it probably makes sense for the bean to use the `ResourceLoader` -interface to load resources. For example, consider the loading of a template of some -sort, where the specific resource that is needed depends on the role of the user. If the -resources are static, it makes sense to eliminate the use of the `ResourceLoader` -interface completely, have the bean expose the `Resource` properties it needs, -and expect them to be injected into it. +of dynamic process, it probably makes sense for the bean to use the `ResourceLoader` or +`ResourcePatternResolver` interface to load resources. For example, consider the loading +of a template of some sort, where the specific resource that is needed depends on the +role of the user. If the resources are static, it makes sense to eliminate the use of the +`ResourceLoader` interface (or `ResourcePatternResolver` interface) completely, have the +bean expose the `Resource` properties it needs, and expect them to be injected into it. What makes it trivial to then inject these properties is that all application contexts register and use a special JavaBeans `PropertyEditor`, which can convert `String` paths -to `Resource` objects. So, if `myBean` has a template property of type `Resource`, it can -be configured with a simple string for that resource, as the following example shows: +to `Resource` objects. For example, the following `MyBean` class has a `template` +property of type `Resource`. + +[source,java,indent=0,subs="verbatim,quotes",role="primary"] +.Java +---- + package example; + + public class MyBean { + + private Resource template; + + public setTemplate(Resource template) { + this.template = template; + } + + // ... + } +---- +[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +.Kotlin +---- + class MyBean(var template: Resource) +---- + +In an XML configuration file, the `template` property can be configured with a simple +string for that resource, as the following example shows: [source,xml,indent=0,subs="verbatim,quotes"] ---- - + ---- -Note that the resource path has no prefix. Consequently, because the application context itself is -going to be used as the `ResourceLoader`, the resource itself is loaded through a -`ClassPathResource`, a `FileSystemResource`, or a `ServletContextResource`, -depending on the exact type of the context. +Note that the resource path has no prefix. Consequently, because the application context +itself is going to be used as the `ResourceLoader`, the resource is loaded through a +`ClassPathResource`, a `FileSystemResource`, or a `ServletContextResource`, depending on +the exact type of the application context. -If you need to force a specific `Resource` type to be used, you can use a prefix. -The following two examples show how to force a `ClassPathResource` and a -`UrlResource` (the latter being used to access a filesystem file): +If you need to force a specific `Resource` type to be used, you can use a prefix. The +following two examples show how to force a `ClassPathResource` and a `UrlResource` (the +latter being used to access a file in the filesystem): [source,xml,indent=0,subs="verbatim,quotes"] ---- @@ -503,6 +528,66 @@ The following two examples show how to force a `ClassPathResource` and a ---- +If the `MyBean` class is refactored for use with annotation-driven configuration, the +path to `myTemplate.txt` can be stored under a key named `template.path` -- for example, +in a properties file made available to the Spring `Environment` (see +<>). The template path can then be referenced via the `@Value` +annotation using a property placeholder (see <>). Spring will +retrieve the value of the template path as a string, and a special `PropertyEditor` will +convert the string to a `Resource` object to be injected into the `MyBean` constructor. +The following example demonstrates how to achieve this. + +[source,java,indent=0,subs="verbatim,quotes",role="primary"] +.Java +---- + @Component + public class MyBean { + + private final Resource template; + + public MyBean(@Value("${template.path}") Resource template) { + this.template = template; + } + + // ... + } +---- +[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +.Kotlin +---- + @Component + class MyBean(@Value("\${template.path}") private val template: Resource) +---- + +If we want to support multiple templates discovered under the same path in multiple +locations in the classpath -- for example, in multiple jars in the classpath -- we can +use the special `classpath*:` prefix and wildcarding to define a `templates.path` key as +`classpath*:/config/templates/*.txt`. If we redefine the `MyBean` class as follows, +Spring will convert the template path pattern into an array of `Resource` objects that +can be injected into the `MyBean` constructor. + +[source,java,indent=0,subs="verbatim,quotes",role="primary"] +.Java +---- + @Component + public class MyBean { + + private final Resource[] templates; + + public MyBean(@Value("${templates.path}") Resource[] templates) { + this.templates = templates; + } + + // ... + } +---- +[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +.Kotlin +---- + @Component + class MyBean(@Value("\${templates.path}") private val templates: Resource[]) +---- + @@ -552,12 +637,12 @@ used. However, consider the following example, which creates a `FileSystemXmlApp val ctx = FileSystemXmlApplicationContext("conf/appContext.xml") ---- -Now the bean definition is loaded from a filesystem location (in this case, relative to +Now the bean definitions are loaded from a filesystem location (in this case, relative to the current working directory). Note that the use of the special `classpath` prefix or a standard URL prefix on the -location path overrides the default type of `Resource` created to load the definition. -Consider the following example: +location path overrides the default type of `Resource` created to load the bean +definitions. Consider the following example: [source,java,indent=0,subs="verbatim,quotes",role="primary"] .Java @@ -663,11 +748,11 @@ contents of the jar file to resolve the wildcards. [[resources-app-ctx-portability]] ===== Implications on Portability -If the specified path is already a file URL (either implicitly because the base +If the specified path is already a `file` URL (either implicitly because the base `ResourceLoader` is a filesystem one or explicitly), wildcarding is guaranteed to work in a completely portable fashion. -If the specified path is a classpath location, the resolver must obtain the last +If the specified path is a `classpath` location, the resolver must obtain the last non-wildcard path segment URL by making a `Classloader.getResource()` call. Since this is just a node of the path (not the file at the end), it is actually undefined (in the `ClassLoader` javadoc) exactly what sort of a URL is returned in this case. In practice, @@ -754,7 +839,7 @@ avoiding the aforementioned portability problems with searching the jar file roo ==== Ant-style patterns with `classpath:` resources are not guaranteed to find matching -resources if the root package to search is available in multiple class path locations. +resources if the root package to search is available in multiple classpath locations. Consider the following example of a resource location: [literal,subs="verbatim,quotes"] @@ -769,12 +854,13 @@ Now consider an Ant-style path that someone might use to try to find that file: classpath:com/mycompany/**/service-context.xml ---- -Such a resource may be in only one location, but when a path such as the preceding example -is used to try to resolve it, the resolver works off the (first) URL returned by -`getResource("com/mycompany");`. If this base package node exists in multiple -`ClassLoader` locations, the actual end resource may not be there. Therefore, in such a case -you should prefer using `classpath*:` with the same Ant-style pattern, which -searches all class path locations that contain the root package. +Such a resource may exist in only one location in the classpath, but when a path such as +the preceding example is used to try to resolve it, the resolver works off the (first) +URL returned by `getResource("com/mycompany");`. If this base package node exists in +multiple `ClassLoader` locations, the desired resource may not exist in the first +location found. Therefore, in such cases you should prefer using `classpath*:` with the +same Ant-style pattern, which searches all classpath locations that contain the +`com.mycompany` base package: `classpath*:com/mycompany/**/service-context.xml`. diff --git a/src/docs/asciidoc/core/core-validation.adoc b/src/docs/asciidoc/core/core-validation.adoc index db0c054ffb84..872d14ae2feb 100644 --- a/src/docs/asciidoc/core/core-validation.adoc +++ b/src/docs/asciidoc/core/core-validation.adoc @@ -1023,8 +1023,8 @@ on the target field, or you might want to run a `Converter` only if a specific m } ---- -A good example of a `ConditionalGenericConverter` is an `EntityConverter` that converts -between a persistent entity identifier and an entity reference. Such an `EntityConverter` +A good example of a `ConditionalGenericConverter` is an `IdToEntityConverter` that converts +between a persistent entity identifier and an entity reference. Such an `IdToEntityConverter` might match only if the target entity type declares a static finder method (for example, `findAccount(Long)`). You might perform such a finder method check in the implementation of `matches(TypeDescriptor, TypeDescriptor)`. diff --git a/src/docs/asciidoc/data-access.adoc b/src/docs/asciidoc/data-access.adoc index 0dc692dad161..104eb810ba49 100644 --- a/src/docs/asciidoc/data-access.adoc +++ b/src/docs/asciidoc/data-access.adoc @@ -4463,13 +4463,14 @@ While this usually works well, there is a potential for issues (for example, wit `null` values). Spring, by default, calls `ParameterMetaData.getParameterType` in such a case, which can be expensive with your JDBC driver. You should use a recent driver version and consider setting the `spring.jdbc.getParameterType.ignore` property to `true` -(as a JVM system property or in a `spring.properties` file in the root of your classpath) -if you encounter a performance issue (as reported on Oracle 12c, JBoss and PostgreSQL). +(as a JVM system property or via the +<> mechanism) if you encounter +a performance issue (as reported on Oracle 12c, JBoss, and PostgreSQL). Alternatively, you might consider specifying the corresponding JDBC types explicitly, -either through a 'BatchPreparedStatementSetter' (as shown earlier), through an explicit type -array given to a 'List' based call, through 'registerSqlType' calls on a -custom 'MapSqlParameterSource' instance, or through a 'BeanPropertySqlParameterSource' +either through a `BatchPreparedStatementSetter` (as shown earlier), through an explicit type +array given to a `List` based call, through `registerSqlType` calls on a +custom `MapSqlParameterSource` instance, or through a `BeanPropertySqlParameterSource` that derives the SQL type from the Java-declared property type even for a null value. ==== diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc index 3c1e2673ab5d..cb2901e8ce4c 100644 --- a/src/docs/asciidoc/index.adoc +++ b/src/docs/asciidoc/index.adoc @@ -24,6 +24,7 @@ WebSocket, RSocket. <> :: Remoting, JMS, JCA, JMX, Email, Tasks, Scheduling, Caching. <> :: Kotlin, Groovy, Dynamic Languages. +<> :: miscellaneous topics. Rod Johnson, Juergen Hoeller, Keith Donald, Colin Sampaleanu, Rob Harrop, Thomas Risberg, Alef Arendsen, Darren Davison, Dmitriy Kopylenko, Mark Pollack, Thierry Templier, Erwin diff --git a/src/docs/asciidoc/testing.adoc b/src/docs/asciidoc/testing.adoc index c8ca05c3d1db..b497edbe3aad 100644 --- a/src/docs/asciidoc/testing.adoc +++ b/src/docs/asciidoc/testing.adoc @@ -1847,7 +1847,7 @@ constructor takes precedence over both `@TestConstructor` and the default mode. The default _test constructor autowire mode_ can be changed by setting the `spring.test.constructor.autowire.mode` JVM system property to `all`. Alternatively, the default mode may be set via the -{api-spring-framework}/core/SpringProperties.html[`SpringProperties`] mechanism. +<> mechanism. As of Spring Framework 5.3, the default mode may also be configured as a https://junit.org/junit5/docs/current/user-guide/#running-tests-config-params[JUnit Platform configuration parameter]. @@ -1880,7 +1880,7 @@ change the default mode. The default _enclosing configuration inheritance mode_ is `INHERIT`, but it can be changed by setting the `spring.test.enclosing.configuration` JVM system property to `OVERRIDE`. Alternatively, the default mode may be set via the -{api-spring-framework}/core/SpringProperties.html[`SpringProperties`] mechanism. +<> mechanism. ===== The <> honors `@NestedTestConfiguration` semantics for the @@ -4567,7 +4567,7 @@ maximum size is reached, a least recently used (LRU) eviction policy is used to close stale contexts. You can configure the maximum size from the command line or a build script by setting a JVM system property named `spring.test.context.cache.maxSize`. As an alternative, you can set the same property via the -{api-spring-framework}/core/SpringProperties.html[`SpringProperties`] mechanism. +<> mechanism. Since having a large number of application contexts loaded within a given test suite can cause the suite to take an unnecessarily long time to run, it is often beneficial to @@ -7471,12 +7471,35 @@ or reactive type such as Reactor `Mono`: [[spring-mvc-test-vs-streaming-response]] ===== Streaming Responses -There are no options built into Spring MVC Test for container-less testing of streaming -responses. However you can test streaming requests through the <>. -This is also supported in Spring Boot where you can -{doc-spring-boot}/html/spring-boot-features.html#boot-features-testing-spring-boot-applications-testing-with-running-server[test a running server] -with `WebTestClient`. One extra advantage is the ability to use the `StepVerifier` from -project Reactor that allows declaring expectations on a stream of data. +The best way to test streaming responses such as Server-Sent Events is through the +<> which can be used as a test client to connect to a `MockMvc` instance +to perform tests on Spring MVC controllers without a running server. For example: + +[source,java,indent=0,subs="verbatim,quotes",role="primary"] +.Java +---- + WebTestClient client = MockMvcWebTestClient.bindToController(new SseController()).build(); + + FluxExchangeResult exchangeResult = client.get() + .uri("/persons") + .exchange() + .expectStatus().isOk() + .expectHeader().contentType("text/event-stream") + .returnResult(Person.class); + + // Use StepVerifier from Project Reactor to test the streaming response + + StepVerifier.create(exchangeResult.getResponseBody()) + .expectNext(new Person("N0"), new Person("N1"), new Person("N2")) + .expectNextCount(4) + .consumeNextWith(person -> assertThat(person.getName()).endsWith("7")) + .thenCancel() + .verify(); +---- + +`WebTestClient` can also connect to a live server and perform full end-to-end integration +tests. This is also supported in Spring Boot where you can +{doc-spring-boot}/html/spring-boot-features.html#boot-features-testing-spring-boot-applications-testing-with-running-server[test a running server]. [[spring-mvc-test-server-filters]] diff --git a/src/docs/asciidoc/web/webflux.adoc b/src/docs/asciidoc/web/webflux.adoc index 406df2a17d6d..dbe8a71d2711 100644 --- a/src/docs/asciidoc/web/webflux.adoc +++ b/src/docs/asciidoc/web/webflux.adoc @@ -1521,7 +1521,7 @@ segments. For example `/resources/{*path}` matches all files under `/resources/` `"path"` variable captures the complete relative path. The syntax `{varName:regex}` declares a URI variable with a regular expression that has the -syntax: `{varName:regex}`. For example, given a URL of `/spring-web-3.0.5 .jar`, the following method +syntax: `{varName:regex}`. For example, given a URL of `/spring-web-3.0.5.jar`, the following method extracts the name, version, and file extension: [source,java,indent=0,subs="verbatim,quotes",role="primary"] diff --git a/src/docs/asciidoc/web/webmvc.adoc b/src/docs/asciidoc/web/webmvc.adoc index b8666a1f8b6c..0e8bdec9b08a 100644 --- a/src/docs/asciidoc/web/webmvc.adoc +++ b/src/docs/asciidoc/web/webmvc.adoc @@ -1085,8 +1085,8 @@ request with a simple request parameter. `MultipartResolver` from the `org.springframework.web.multipart` package is a strategy for parsing multipart requests including file uploads. There is one implementation -based on https://jakarta.apache.org/commons/fileupload[Commons FileUpload] and another -based on Servlet 3.0 multipart request parsing. +based on https://commons.apache.org/proper/commons-fileupload[Commons FileUpload] and +another based on Servlet 3.0 multipart request parsing. To enable multipart handling, you need to declare a `MultipartResolver` bean in your `DispatcherServlet` Spring configuration with a name of `multipartResolver`. @@ -1618,7 +1618,7 @@ leave that detail out if the names are the same and your code is compiled with d information or with the `-parameters` compiler flag on Java 8. The syntax `{varName:regex}` declares a URI variable with a regular expression that has -syntax of `{varName:regex}`. For example, given URL `"/spring-web-3.0.5 .jar"`, the following method +syntax of `{varName:regex}`. For example, given URL `"/spring-web-3.0.5.jar"`, the following method extracts the name, version, and file extension: [source,java,indent=0,subs="verbatim,quotes",role="primary"]